feat(store): lit bindings (#289)

This commit is contained in:
rahim
2026-01-06 22:26:09 +11:00
committed by GitHub
parent 36ea73f424
commit 648aae7e31
50 changed files with 3561 additions and 412 deletions
@@ -0,0 +1,23 @@
export type { AsyncStatus } from '../../core/queue';
export { MutationController } from './mutation-controller';
export type {
MutationError,
MutationIdle,
MutationPending,
MutationResult,
MutationSuccess,
} from './mutation-controller';
export { OptimisticController } from './optimistic-controller';
export type {
OptimisticError,
OptimisticIdle,
OptimisticPending,
OptimisticResult,
OptimisticSuccess,
} from './optimistic-controller';
export { RequestController } from './request-controller';
export { SelectorController } from './selector-controller';
export { TasksController } from './tasks-controller';
@@ -0,0 +1,139 @@
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
import type { EnsureFunction } from '@videojs/utils/types';
import type { AsyncStatus, Task } from '../../core/queue';
import type { AnyStore, InferStoreRequests } from '../../core/store';
import { noop } from '@videojs/utils/function';
// ----------------------------------------
// Mutation Types
// ----------------------------------------
interface MutationBase<Mutate> {
status: AsyncStatus;
mutate: Mutate;
reset: () => void;
}
export interface MutationIdle<Mutate> extends MutationBase<Mutate> {
status: 'idle';
}
export interface MutationPending<Mutate> extends MutationBase<Mutate> {
status: 'pending';
}
export interface MutationSuccess<Mutate, Data> extends MutationBase<Mutate> {
status: 'success';
data: Data;
}
export interface MutationError<Mutate> extends MutationBase<Mutate> {
status: 'error';
error: unknown;
}
export type MutationResult<Mutate, Data>
= | MutationIdle<Mutate>
| MutationPending<Mutate>
| MutationSuccess<Mutate, Data>
| MutationError<Mutate>;
// ----------------------------------------
// Controller
// ----------------------------------------
/**
* Tracks a mutation's status with discriminated union result.
* Triggers host updates when the task status changes.
*
* @example
* ```ts
* class MyElement extends LitElement {
* #playMutation = new MutationController(this, store, 'play');
*
* render() {
* const mutation = this.#playMutation.value;
* return html`
* <button
* @click=${() => mutation.mutate()}
* ?disabled=${mutation.status === 'pending'}
* >
* ...
* </button>
* `;
* }
* }
* ```
*/
export class MutationController<
Store extends AnyStore,
Name extends keyof InferStoreRequests<Store>,
Mutate extends InferStoreRequests<Store>[Name] = InferStoreRequests<Store>[Name],
> implements ReactiveController {
readonly #host: ReactiveControllerHost;
readonly #store: Store;
readonly #name: Name;
#task: Task | undefined;
#unsubscribe = noop;
constructor(host: ReactiveControllerHost, store: Store, name: Name) {
this.#host = host;
this.#store = store;
this.#name = name;
this.#task = store.queue.tasks[name];
host.addController(this);
}
get value(): MutationResult<Mutate, Awaited<ReturnType<EnsureFunction<Mutate>>>> {
const task = this.#task;
const base = {
mutate: this.#store.request[this.#name] as Mutate,
reset: this.#reset,
};
if (task?.status === 'success') {
return {
status: 'success',
...base,
data: task.output as any,
};
}
if (task?.status === 'error') {
return {
status: 'error',
...base,
error: task.error,
};
}
return {
status: task?.status ?? 'idle',
...base,
};
}
#reset = () => {
this.#store.queue.reset(this.#name);
};
hostConnected() {
this.#task = this.#store.queue.tasks[this.#name];
this.#unsubscribe = this.#store.queue.subscribe((tasks) => {
const newTask = tasks[this.#name];
if (newTask !== this.#task) {
this.#task = newTask;
this.#host.requestUpdate();
}
});
}
hostDisconnected() {
this.#unsubscribe();
this.#unsubscribe = noop;
}
}
@@ -0,0 +1,165 @@
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
import type { EnsureFunction } from '@videojs/utils/types';
import type { Task } from '../../core/queue';
import type { AnyStore, InferStoreRequests, InferStoreState } from '../../core/store';
import { Disposer } from '@videojs/utils/events';
// ----------------------------------------
// Optimistic Types
// ----------------------------------------
interface OptimisticBase<Value, SetValue> {
value: Value;
setValue: SetValue;
reset: () => void;
}
export interface OptimisticIdle<Value, SetValue> extends OptimisticBase<Value, SetValue> {
status: 'idle';
}
export interface OptimisticPending<Value, SetValue> extends OptimisticBase<Value, SetValue> {
status: 'pending';
}
export interface OptimisticSuccess<Value, SetValue> extends OptimisticBase<Value, SetValue> {
status: 'success';
}
export interface OptimisticError<Value, SetValue> extends OptimisticBase<Value, SetValue> {
status: 'error';
error: unknown;
}
export type OptimisticResult<Value, SetValue>
= | OptimisticIdle<Value, SetValue>
| OptimisticPending<Value, SetValue>
| OptimisticSuccess<Value, SetValue>
| OptimisticError<Value, SetValue>;
// ----------------------------------------
// Controller
// ----------------------------------------
/**
* Shows optimistic value while mutation is pending, actual value otherwise.
* When setValue is called, immediately shows the new value while the request
* is in flight. Reverts to actual value if the request fails.
*
* @example
* ```ts
* class VolumeSlider extends LitElement {
* #volume = new OptimisticController(this, store, 'setVolume', s => s.volume);
*
* render() {
* const { value, setValue, status } = this.#volume.value;
* return html`
* <input
* type="range"
* .value=${value}
* @input=${(e) => setValue(Number(e.target.value))}
* style="opacity: ${status === 'pending' ? 0.5 : 1}"
* />
* `;
* }
* }
* ```
*/
export class OptimisticController<
Store extends AnyStore,
Name extends keyof InferStoreRequests<Store>,
Value,
Request extends InferStoreRequests<Store>[Name] = InferStoreRequests<Store>[Name],
> implements ReactiveController {
readonly #host: ReactiveControllerHost;
readonly #store: Store;
readonly #name: Name;
readonly #selector: (state: InferStoreState<Store>) => Value;
readonly #disposer = new Disposer();
#optimistic: Value | null = null;
#task: Task | undefined;
constructor(
host: ReactiveControllerHost,
store: Store,
name: Name,
selector: (state: InferStoreState<Store>) => Value,
) {
this.#host = host;
this.#store = store;
this.#name = name;
this.#selector = selector;
this.#task = store.queue.tasks[name];
host.addController(this);
}
get value(): OptimisticResult<Value, (value: Value) => ReturnType<EnsureFunction<Request>>> {
const task = this.#task;
// Show optimistic value when set (cleared on task settlement)
const value = this.#optimistic !== null ? this.#optimistic : this.#selector(this.#store.state);
const base = {
value,
setValue: this.#setValue,
reset: this.#reset,
};
if (task?.status === 'error') {
return {
status: 'error',
...base,
error: task.error,
};
}
return {
status: task?.status ?? 'idle',
...base,
};
}
#setValue = (newValue: Value): ReturnType<EnsureFunction<Request>> => {
this.#optimistic = newValue;
this.#host.requestUpdate();
const request = this.#store.request[this.#name] as (value: Value) => ReturnType<EnsureFunction<Request>>;
return request(newValue);
};
#reset = (): void => {
this.#optimistic = null;
this.#host.requestUpdate();
this.#task = this.#store.queue.tasks[this.#name];
if (this.#task) this.#store.queue.reset(this.#name);
};
hostConnected() {
this.#task = this.#store.queue.tasks[this.#name];
this.#disposer.add(this.#store.subscribe(this.#selector, () => this.#host.requestUpdate()));
this.#disposer.add(
this.#store.queue.subscribe((tasks) => {
const newTask = tasks[this.#name];
if (newTask !== this.#task) {
this.#task = newTask;
// Clear optimistic value when task settles
if (this.#optimistic !== null && newTask?.status !== 'pending') {
this.#optimistic = null;
}
this.#host.requestUpdate();
}
}),
);
}
hostDisconnected() {
this.#disposer.dispose();
}
}
@@ -0,0 +1,37 @@
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
import type { AnyStore, InferStoreRequests } from '../../core/store';
/**
* Provides access to a store request by key.
*
* @example
* ```ts
* class MyElement extends LitElement {
* #play = new RequestController(this, store, 'play');
*
* render() {
* return html`<button @click=${() => this.#play.value()}>Play</button>`;
* }
* }
* ```
*/
export class RequestController<
Store extends AnyStore,
Name extends keyof InferStoreRequests<Store>,
> implements ReactiveController {
readonly #store: Store;
readonly #name: Name;
constructor(host: ReactiveControllerHost, store: Store, name: Name) {
this.#store = store;
this.#name = name;
host.addController(this);
}
get value(): InferStoreRequests<Store>[Name] {
return this.#store.request[this.#name] as InferStoreRequests<Store>[Name];
}
// no-op to satisfy `ReactiveController` interface
hostConnected() {}
}
@@ -0,0 +1,54 @@
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
import type { AnyStore, InferStoreState } from '../../core/store';
import { noop } from '@videojs/utils/function';
/**
* Subscribes to a selected portion of store state.
* Triggers host updates when the selected value changes.
*
* @example
* ```ts
* class MyElement extends LitElement {
* #paused = new SelectorController(this, store, s => s.paused);
*
* render() {
* return html`<button>${this.#paused.value ? 'Play' : 'Pause'}</button>`;
* }
* }
* ```
*/
export class SelectorController<Store extends AnyStore, Value> implements ReactiveController {
readonly #host: ReactiveControllerHost;
readonly #store: Store;
readonly #selector: (state: InferStoreState<Store>) => Value;
#value: Value;
#unsubscribe = noop;
constructor(host: ReactiveControllerHost, store: Store, selector: (state: InferStoreState<Store>) => Value) {
this.#host = host;
this.#store = store;
this.#selector = selector;
this.#value = selector(store.state);
host.addController(this);
}
get value(): Value {
return this.#value;
}
hostConnected() {
// Sync value on reconnect to avoid stale state
this.#value = this.#selector(this.#store.state);
this.#unsubscribe = this.#store.subscribe(this.#selector, (value) => {
this.#value = value;
this.#host.requestUpdate();
});
}
hostDisconnected() {
this.#unsubscribe();
this.#unsubscribe = noop;
}
}
@@ -0,0 +1,55 @@
import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element';
import type { AnyStore } from '../../core/store';
import { noop } from '@videojs/utils/function';
/**
* Subscribes to task state changes.
*
* Triggers host updates when tasks change.
*
* @example
* ```ts
* class MyElement extends LitElement {
* #tasks = new TasksController(this, store);
*
* render() {
* const playTask = this.#tasks.value.play;
* const isPending = playTask?.status === 'pending';
* return html`<button ?disabled=${isPending}>Play</button>`;
* }
* }
* ```
*/
export class TasksController<Store extends AnyStore> implements ReactiveController {
readonly #host: ReactiveControllerHost;
readonly #store: Store;
#value: Store['queue']['tasks'];
#unsubscribe = noop;
constructor(host: ReactiveControllerHost, store: Store) {
this.#host = host;
this.#store = store;
this.#value = store.queue.tasks;
host.addController(this);
}
get value(): Store['queue']['tasks'] {
return this.#value;
}
hostConnected() {
// Sync value on reconnect to avoid stale state
this.#value = this.#store.queue.tasks;
this.#unsubscribe = this.#store.queue.subscribe((tasks) => {
this.#value = tasks;
this.#host.requestUpdate();
});
}
hostDisconnected() {
this.#unsubscribe();
this.#unsubscribe = noop;
}
}
@@ -0,0 +1,252 @@
import { noop } from '@videojs/utils/function';
import { describe, expect, it } from 'vitest';
import { createSlice } from '../../../core/slice';
import { createStore as createCoreStore } from '../../../core/store';
import { createCoreTestStore, createCustomKeyTestStore, createMockHost, MockMedia } from '../../tests/test-utils';
import { MutationController } from '../mutation-controller';
describe('MutationController', () => {
it('returns mutation result with idle status initially', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
expect(controller.value.status).toBe('idle');
});
it('registers with host', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
expect(host.controllers.has(controller)).toBe(true);
});
it('provides mutate function', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
expect(typeof controller.value.mutate).toBe('function');
});
it('tracks success state with data', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
controller.hostConnected();
await controller.value.mutate(0.7);
expect(controller.value.status).toBe('success');
if (controller.value.status === 'success') {
expect(controller.value.data).toBe(0.7);
}
expect(host.updateCount).toBeGreaterThan(0);
});
it('reset clears settled state', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
controller.hostConnected();
await controller.value.mutate(0.5);
expect(controller.value.status).toBe('success');
controller.value.reset();
expect(controller.value.status).toBe('idle');
});
it('unsubscribes on hostDisconnected', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
controller.hostConnected();
controller.hostDisconnected();
const updateCountBefore = host.updateCount;
await store.request.setVolume!(0.5);
expect(host.updateCount).toBe(updateCountBefore);
});
it('tracks error with error object', async () => {
const host = createMockHost();
// Create a slice with a failing request for testing
const failingSlice = createSlice<MockMedia>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({
volume: target.volume,
muted: target.muted,
}),
subscribe: () => {},
request: {
failingRequest: async () => {
throw new Error('Test error');
},
},
});
const failingStore = createCoreStore({
slices: [failingSlice],
onError: noop,
});
const target = new MockMedia();
failingStore.attach(target);
const controller = new MutationController(host, failingStore, 'failingRequest');
controller.hostConnected();
try {
await controller.value.mutate();
} catch {
// Expected to throw
}
expect(controller.value.status).toBe('error');
if (controller.value.status === 'error') {
expect(controller.value.error).toBeInstanceOf(Error);
expect((controller.value.error as Error).message).toBe('Test error');
}
});
it('tracks pending status while mutation is in flight', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'slowSetVolume');
controller.hostConnected();
const promise = controller.value.mutate(0.5);
// Wait for task to start
await new Promise(resolve => setTimeout(resolve, 10));
expect(controller.value.status).toBe('pending');
await promise;
expect(controller.value.status).toBe('success');
});
it('syncs state on reconnect after disconnect', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
controller.hostConnected();
await controller.value.mutate(0.5);
expect(controller.value.status).toBe('success');
controller.hostDisconnected();
// Trigger another mutation while disconnected
await store.request.setVolume!(0.8);
// Reconnect - should sync to current task state
controller.hostConnected();
expect(controller.value.status).toBe('success');
});
describe('custom key (name !== key)', () => {
it('tracks task by name when key differs', async () => {
const { store } = createCustomKeyTestStore();
const host = createMockHost();
// adjustVolume has name='adjustVolume' but key='audio-settings'
const controller = new MutationController(host, store, 'adjustVolume');
controller.hostConnected();
const promise = controller.value.mutate(0.5);
// Wait for task to start
await new Promise(resolve => setTimeout(resolve, 5));
expect(controller.value.status).toBe('pending');
await promise;
expect(controller.value.status).toBe('success');
if (controller.value.status === 'success') {
expect(controller.value.data).toBe(0.5);
}
});
it('tracks correct task when multiple requests share same key', async () => {
const { store } = createCustomKeyTestStore();
const hostVolume = createMockHost();
const hostMute = createMockHost();
// Both adjustVolume and toggleMute have key='audio-settings'
const volumeController = new MutationController(hostVolume, store, 'adjustVolume');
const muteController = new MutationController(hostMute, store, 'toggleMute');
volumeController.hostConnected();
muteController.hostConnected();
// Start volume adjustment
const volumePromise = volumeController.value.mutate(0.5);
await new Promise(resolve => setTimeout(resolve, 5));
// Volume controller should be pending
expect(volumeController.value.status).toBe('pending');
// Mute controller should be idle (different name, even though same key)
expect(muteController.value.status).toBe('idle');
await volumePromise;
expect(volumeController.value.status).toBe('success');
expect(muteController.value.status).toBe('idle');
});
it('superseded task shows error status', async () => {
const { store } = createCustomKeyTestStore();
const hostVolume = createMockHost();
const hostMute = createMockHost();
const volumeController = new MutationController(hostVolume, store, 'adjustVolume');
const muteController = new MutationController(hostMute, store, 'toggleMute');
volumeController.hostConnected();
muteController.hostConnected();
// Start volume adjustment
const volumePromise = volumeController.value.mutate(0.5);
await new Promise(resolve => setTimeout(resolve, 5));
// Start mute toggle - this will supersede volume because same key
const mutePromise = muteController.value.mutate(true);
// Wait for superseding to happen
await new Promise(resolve => setTimeout(resolve, 5));
// Volume task was superseded
try {
await volumePromise;
} catch {
// Expected - task was superseded
}
// Tasks are keyed by name, so superseded task shows error status
expect(volumeController.value.status).toBe('error');
expect(muteController.value.status).toBe('pending');
await mutePromise;
expect(muteController.value.status).toBe('success');
});
});
});
@@ -0,0 +1,302 @@
import { noop } from '@videojs/utils/function';
import { describe, expect, it } from 'vitest';
import { createSlice } from '../../../core/slice';
import { createStore as createCoreStore } from '../../../core/store';
import { createCoreTestStore, createCustomKeyTestStore, createMockHost, MockMedia } from '../../tests/test-utils';
import { OptimisticController } from '../optimistic-controller';
describe('OptimisticController', () => {
it('returns actual value initially', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
expect(controller.value.value).toBe(1);
expect(controller.value.status).toBe('idle');
});
it('registers with host', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
expect(host.controllers.has(controller)).toBe(true);
});
it('provides setValue function', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
expect(typeof controller.value.setValue).toBe('function');
});
it('updates actual value after mutation completes', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
controller.hostConnected();
await controller.value.setValue(0.3);
expect(controller.value.value).toBe(0.3);
expect(controller.value.status).toBe('success');
});
it('reset clears error state', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
controller.hostConnected();
await controller.value.setValue(0.5);
controller.value.reset();
expect(controller.value.status).toBe('idle');
});
it('triggers host update when state changes', async () => {
const { store, target } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
controller.hostConnected();
target.volume = 0.8;
target.dispatchEvent(new Event('volumechange'));
expect(host.updateCount).toBeGreaterThan(0);
expect(controller.value.value).toBe(0.8);
});
it('unsubscribes on hostDisconnected', async () => {
const { store, target } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
controller.hostConnected();
controller.hostDisconnected();
const updateCountBefore = host.updateCount;
target.volume = 0.2;
target.dispatchEvent(new Event('volumechange'));
expect(host.updateCount).toBe(updateCountBefore);
});
it('shows optimistic value immediately while pending', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'slowSetVolume', s => s.volume);
controller.hostConnected();
const promise = controller.value.setValue(0.3);
// Optimistic value shown immediately
expect(controller.value.value).toBe(0.3);
expect(host.updateCount).toBeGreaterThan(0);
// Wait for task to start
await new Promise(resolve => setTimeout(resolve, 10));
expect(controller.value.status).toBe('pending');
expect(controller.value.value).toBe(0.3);
await promise;
// After completion, shows actual value
expect(controller.value.status).toBe('success');
expect(controller.value.value).toBe(0.3);
});
it('reverts to actual value on error', async () => {
const host = createMockHost();
const failingSlice = createSlice<MockMedia>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({
volume: target.volume,
muted: target.muted,
}),
subscribe: () => {},
request: {
failingSetVolume: async () => {
await new Promise(resolve => setTimeout(resolve, 10));
throw new Error('Test error');
},
},
});
const failingStore = createCoreStore({
slices: [failingSlice],
onError: noop,
});
const target = new MockMedia();
failingStore.attach(target);
const controller = new OptimisticController(host, failingStore, 'failingSetVolume', s => s.volume);
controller.hostConnected();
const promise = controller.value.setValue(0.5);
// Optimistic value shown immediately
expect(controller.value.value).toBe(0.5);
try {
await promise;
} catch {
// Expected
}
// After error, shows actual value (reverted)
expect(controller.value.status).toBe('error');
expect(controller.value.value).toBe(1); // Original value
if (controller.value.status === 'error') {
expect(controller.value.error).toBeInstanceOf(Error);
}
});
it('handles rapid setValue calls (superseding)', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'slowSetVolume', s => s.volume);
controller.hostConnected();
// Fire multiple rapid calls
const promise1 = controller.value.setValue(0.3);
const promise2 = controller.value.setValue(0.5);
const promise3 = controller.value.setValue(0.7);
// Should show latest optimistic value
expect(controller.value.value).toBe(0.7);
// First two get superseded
await expect(promise1).rejects.toMatchObject({ code: 'SUPERSEDED' });
await expect(promise2).rejects.toMatchObject({ code: 'SUPERSEDED' });
await promise3;
expect(controller.value.status).toBe('success');
expect(controller.value.value).toBe(0.7);
});
it('reset when already idle is safe', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
controller.hostConnected();
expect(controller.value.status).toBe('idle');
// Reset when idle should not throw
controller.value.reset();
expect(controller.value.status).toBe('idle');
expect(controller.value.value).toBe(1);
});
describe('custom key (name !== key)', () => {
it('tracks task by name when key differs', async () => {
const { store } = createCustomKeyTestStore();
const host = createMockHost();
// adjustVolume has name='adjustVolume' but key='audio-settings'
const controller = new OptimisticController(host, store, 'adjustVolume', s => s.volume);
controller.hostConnected();
const promise = controller.value.setValue(0.5);
// Optimistic value shown immediately
expect(controller.value.value).toBe(0.5);
// Wait for task to start
await new Promise(resolve => setTimeout(resolve, 5));
expect(controller.value.status).toBe('pending');
await promise;
expect(controller.value.status).toBe('success');
expect(controller.value.value).toBe(0.5);
});
it('tracks correct task when multiple requests share same key', async () => {
const { store } = createCustomKeyTestStore();
const hostVolume = createMockHost();
const hostMute = createMockHost();
// Both adjustVolume and toggleMute have key='audio-settings'
const volumeController = new OptimisticController(hostVolume, store, 'adjustVolume', s => s.volume);
const muteController = new OptimisticController(hostMute, store, 'toggleMute', s => s.muted);
volumeController.hostConnected();
muteController.hostConnected();
// Start volume adjustment
const volumePromise = volumeController.value.setValue(0.5);
await new Promise(resolve => setTimeout(resolve, 5));
// Volume controller should be pending
expect(volumeController.value.status).toBe('pending');
expect(volumeController.value.value).toBe(0.5); // Optimistic
// Mute controller should be idle (different name, even though same key)
expect(muteController.value.status).toBe('idle');
expect(muteController.value.value).toBe(false); // Actual
await volumePromise;
expect(volumeController.value.status).toBe('success');
expect(muteController.value.status).toBe('idle');
});
it('superseded task shows error status', async () => {
const { store } = createCustomKeyTestStore();
const hostVolume = createMockHost();
const hostMute = createMockHost();
const volumeController = new OptimisticController(hostVolume, store, 'adjustVolume', s => s.volume);
const muteController = new OptimisticController(hostMute, store, 'toggleMute', s => s.muted);
volumeController.hostConnected();
muteController.hostConnected();
// Start volume adjustment
const volumePromise = volumeController.value.setValue(0.5);
await new Promise(resolve => setTimeout(resolve, 5));
// Start mute toggle - this will supersede volume because same key
const mutePromise = muteController.value.setValue(true);
// Mute shows optimistic immediately
expect(muteController.value.value).toBe(true);
// Volume task was superseded
try {
await volumePromise;
} catch {
// Expected - task was superseded
}
// Wait for subscription callbacks to fire
await new Promise(resolve => setTimeout(resolve, 10));
// Tasks are keyed by name, so superseded task shows error status
expect(volumeController.value.status).toBe('error');
// Complete the mute operation
await mutePromise;
expect(muteController.value.status).toBe('success');
});
});
});
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { createCoreTestStore, createMockHost } from '../../tests/test-utils';
import { RequestController } from '../request-controller';
describe('RequestController', () => {
it('returns request function', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new RequestController(host, store, 'setVolume');
expect(typeof controller.value).toBe('function');
});
it('registers with host', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new RequestController(host, store, 'setVolume');
expect(host.controllers.has(controller)).toBe(true);
});
it('returns stable reference', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new RequestController(host, store, 'setVolume');
const first = controller.value;
const second = controller.value;
expect(first).toBe(second);
});
it('request works correctly', async () => {
const { store, target } = createCoreTestStore();
const host = createMockHost();
const controller = new RequestController(host, store, 'setVolume');
await controller.value(0.7);
expect(target.volume).toBe(0.7);
});
});
@@ -0,0 +1,117 @@
import { describe, expect, it } from 'vitest';
import { createCoreTestStore, createMockHost } from '../../tests/test-utils';
import { SelectorController } from '../selector-controller';
describe('SelectorController', () => {
it('returns selected state', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new SelectorController(host, store, s => s.volume);
expect(controller.value).toBe(1);
});
it('registers with host', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new SelectorController(host, store, s => s.volume);
expect(host.controllers.has(controller)).toBe(true);
});
it('subscribes on hostConnected', () => {
const { store, target } = createCoreTestStore();
const host = createMockHost();
const controller = new SelectorController(host, store, s => s.volume);
controller.hostConnected();
target.volume = 0.5;
target.dispatchEvent(new Event('volumechange'));
expect(controller.value).toBe(0.5);
expect(host.updateCount).toBe(1);
});
it('unsubscribes on hostDisconnected', () => {
const { store, target } = createCoreTestStore();
const host = createMockHost();
const controller = new SelectorController(host, store, s => s.volume);
controller.hostConnected();
controller.hostDisconnected();
const updateCountBefore = host.updateCount;
target.volume = 0.3;
target.dispatchEvent(new Event('volumechange'));
expect(host.updateCount).toBe(updateCountBefore);
});
it('syncs value on reconnect after state changed while disconnected', () => {
const { store, target } = createCoreTestStore();
const host = createMockHost();
const controller = new SelectorController(host, store, s => s.volume);
controller.hostConnected();
expect(controller.value).toBe(1);
controller.hostDisconnected();
target.volume = 0.3;
target.dispatchEvent(new Event('volumechange'));
// Value should still be stale (not subscribed)
expect(controller.value).toBe(1);
// Reconnect - should have current value
controller.hostConnected();
expect(controller.value).toBe(0.3);
});
it('does not trigger update when unrelated state changes', () => {
const { store, target } = createCoreTestStore();
const host = createMockHost();
const controller = new SelectorController(host, store, s => s.volume);
controller.hostConnected();
target.muted = true;
target.dispatchEvent(new Event('volumechange'));
// Volume didn't change, so no update should be triggered
expect(host.updateCount).toBe(0);
});
it('handles multiple reconnect cycles', () => {
const { store, target } = createCoreTestStore();
const host = createMockHost();
const controller = new SelectorController(host, store, s => s.volume);
// First connect/disconnect
controller.hostConnected();
target.volume = 0.5;
target.dispatchEvent(new Event('volumechange'));
expect(controller.value).toBe(0.5);
controller.hostDisconnected();
// Change while disconnected
target.volume = 0.3;
target.dispatchEvent(new Event('volumechange'));
// Second connect - should sync to current value
controller.hostConnected();
expect(controller.value).toBe(0.3);
// Changes should work again
target.volume = 0.8;
target.dispatchEvent(new Event('volumechange'));
expect(controller.value).toBe(0.8);
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest';
import { createCoreTestStore, createMockHost } from '../../tests/test-utils';
import { TasksController } from '../tasks-controller';
describe('TasksController', () => {
it('returns tasks record', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new TasksController(host, store);
expect(controller.value).toEqual({});
});
it('registers with host', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new TasksController(host, store);
expect(host.controllers.has(controller)).toBe(true);
});
it('updates when task completes', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new TasksController(host, store);
controller.hostConnected();
expect(controller.value.setVolume).toBeUndefined();
await store.request.setVolume!(0.5);
expect(controller.value.setVolume).toBeDefined();
expect(controller.value.setVolume?.status).toBe('success');
expect(host.updateCount).toBeGreaterThan(0);
});
it('unsubscribes on hostDisconnected', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new TasksController(host, store);
controller.hostConnected();
controller.hostDisconnected();
const updateCountBefore = host.updateCount;
await store.request.setVolume!(0.5);
expect(host.updateCount).toBe(updateCountBefore);
});
it('handles multiple task updates', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new TasksController(host, store);
controller.hostConnected();
// Fire multiple requests
await store.request.setVolume!(0.5);
await store.request.setMuted!(true);
// Both tasks should be tracked
expect(controller.value.setVolume).toBeDefined();
expect(controller.value.setMuted).toBeDefined();
expect(controller.value.setVolume?.status).toBe('success');
expect(controller.value.setMuted?.status).toBe('success');
});
it('syncs to current tasks on reconnect', async () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new TasksController(host, store);
controller.hostConnected();
await store.request.setVolume!(0.5);
expect(controller.value.setVolume?.status).toBe('success');
controller.hostDisconnected();
// Trigger another task while disconnected
await store.request.setMuted!(true);
// Reconnect - should sync to current tasks
controller.hostConnected();
expect(controller.value.setVolume?.status).toBe('success');
expect(controller.value.setMuted?.status).toBe('success');
});
});
@@ -0,0 +1,143 @@
import type { MutationResult, OptimisticResult } from '../index';
import { describe, expectTypeOf, it } from 'vitest';
import { createCoreTestStore, createMockHost } from '../../tests/test-utils';
import { MutationController } from '../mutation-controller';
import { OptimisticController } from '../optimistic-controller';
import { RequestController } from '../request-controller';
import { SelectorController } from '../selector-controller';
import { TasksController } from '../tasks-controller';
describe('controller types', () => {
describe('SelectorController', () => {
it('value has selected type', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new SelectorController(host, store, s => s.volume);
expectTypeOf(controller.value).toEqualTypeOf<number>();
});
it('value type matches selector return type', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new SelectorController(host, store, s => ({
volume: s.volume,
muted: s.muted,
}));
expectTypeOf(controller.value).toEqualTypeOf<{ volume: number; muted: boolean }>();
});
});
describe('RequestController', () => {
it('value is the request function', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new RequestController(host, store, 'setVolume');
expectTypeOf(controller.value).toBeFunction();
expectTypeOf(controller.value).parameter(0).toEqualTypeOf<number>();
});
});
describe('MutationController', () => {
it('value is MutationResult', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
expectTypeOf(controller.value).toExtend<MutationResult<unknown, unknown>>();
});
it('value has mutate function', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
expectTypeOf(controller.value.mutate).toBeFunction();
});
it('value has reset function', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
expectTypeOf(controller.value.reset).toEqualTypeOf<() => void>();
});
it('value has status property', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new MutationController(host, store, 'setVolume');
expectTypeOf(controller.value.status).toEqualTypeOf<'idle' | 'pending' | 'success' | 'error'>();
});
});
describe('OptimisticController', () => {
it('value is OptimisticResult', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
expectTypeOf(controller.value).toExtend<OptimisticResult<unknown, unknown>>();
});
it('value has selected type', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
expectTypeOf(controller.value.value).toEqualTypeOf<number>();
});
it('value has setValue function', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
expectTypeOf(controller.value.setValue).toBeFunction();
});
it('value has reset function', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
expectTypeOf(controller.value.reset).toEqualTypeOf<() => void>();
});
it('value has status property', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new OptimisticController(host, store, 'setVolume', s => s.volume);
expectTypeOf(controller.value.status).toEqualTypeOf<'idle' | 'pending' | 'success' | 'error'>();
});
});
describe('TasksController', () => {
it('value is tasks record', () => {
const { store } = createCoreTestStore();
const host = createMockHost();
const controller = new TasksController(host, store);
expectTypeOf(controller.value).toEqualTypeOf<typeof store.queue.tasks>();
});
});
});
+134
View File
@@ -0,0 +1,134 @@
import type { Context } from '@lit/context';
import type { ReactiveElement } from '@lit/reactive-element';
import type { Constructor } from '@videojs/utils/types';
import type { AnySlice, UnionSliceTarget } from '../core/slice';
import type { StoreConfig, StoreConsumer, StoreProvider } from '../core/store';
import { createContext } from '@lit/context';
import { Store } from '../core/store';
import { createStoreAttachMixin, createStoreMixin, createStoreProviderMixin } from './mixins';
export const contextKey = Symbol('@videojs/store');
export interface CreateStoreConfig<Slices extends AnySlice[]> extends StoreConfig<UnionSliceTarget<Slices>, Slices> {}
export interface CreateStoreResult<Slices extends AnySlice[]> {
/**
* Combined mixin: provides store via context AND auto-attaches slotted media.
*
* @example
* ```ts
* class MyPlayer extends StoreMixin(LitElement) {}
* ```
*/
StoreMixin: <T extends Constructor<ReactiveElement>>(Base: T) => T & Constructor<StoreProvider<Slices>>;
/**
* Mixin that provides store via context (no auto-attach).
*
* Use when you need granular control over store provisioning.
*
* @example
* ```ts
* class MyProvider extends StoreProviderMixin(LitElement) {}
* ```
*/
StoreProviderMixin: <T extends Constructor<ReactiveElement>>(Base: T) => T & Constructor<StoreProvider<Slices>>;
/**
* Mixin that auto-attaches slotted media elements (requires store from context).
*
* Use when inheriting store from a parent provider.
*
* @example
* ```ts
* class MyControls extends StoreAttachMixin(LitElement) {}
* ```
*/
StoreAttachMixin: <T extends Constructor<ReactiveElement>>(Base: T) => T & Constructor<StoreConsumer<Slices>>;
/**
* Context for consuming store in controllers.
*
* Use this with Lit's `ContextConsumer` or the `@consume` decorator.
*
* @example
* ```ts
* class MyElement extends LitElement {
* @consume({ context, subscribe: true })
* readonly store!: ContextType<typeof context>;
* }
* ```
*/
context: Context<typeof contextKey, Store<UnionSliceTarget<Slices>, Slices>>;
/**
* Creates a store instance for imperative access.
*
* Useful for creating a store before rendering or for testing.
*
* @example
* ```ts
* const store = create();
* store.attach(videoElement);
* ```
*/
create: () => Store<UnionSliceTarget<Slices>, Slices>;
}
/**
* Creates a store factory that returns mixins, context, and a create function.
*
* @param config - Store configuration including slices and optional lifecycle hooks
* @returns An object containing mixins, context, and create function
*
* @example
* ```ts
* import { createStore } from '@videojs/store/lit';
* import { playbackSlice } from '@videojs/core/dom';
*
* const { StoreMixin } = createStore({
* slices: [playbackSlice],
* });
*
* // Create a player element with store
* class MyPlayer extends StoreMixin(LitElement) {}
*
* customElements.define('my-player', MyPlayer);
* ```
*
* @example
* ```html
* <my-player>
* <video src="video.mp4"></video>
* </my-player>
* ```
*/
export function createStore<Slices extends AnySlice[]>(config: CreateStoreConfig<Slices>): CreateStoreResult<Slices> {
type Target = UnionSliceTarget<Slices>;
type ProvidedStore = Store<Target, Slices>;
const context = createContext<ProvidedStore, typeof contextKey>(contextKey);
function create(): ProvidedStore {
return new Store(config);
}
const StoreProviderMixin = createStoreProviderMixin<Slices>(
context,
create,
);
const StoreAttachMixin = createStoreAttachMixin<Slices>(context);
const StoreMixin = createStoreMixin<Slices>(context, create);
return {
StoreMixin,
StoreProviderMixin,
StoreAttachMixin,
context,
create,
};
}
+20
View File
@@ -0,0 +1,20 @@
// Controllers
export {
MutationController,
OptimisticController,
RequestController,
SelectorController,
TasksController,
} from './controllers';
export type {
AsyncStatus,
MutationResult,
OptimisticResult,
} from './controllers';
// createStore factory
export { createStore } from './create-store';
export type { contextKey, CreateStoreConfig, CreateStoreResult } from './create-store';
// Mixin factories (for advanced use cases)
export { createStoreAttachMixin, createStoreMixin, createStoreProviderMixin } from './mixins';
@@ -0,0 +1,89 @@
import type { Context } from '@lit/context';
import type { ReactiveElement } from '@lit/reactive-element';
import type { Constructor, Mixin } from '@videojs/utils/types';
import type { AnySlice, UnionSliceTarget } from '../../core/slice';
import type { Store, StoreConsumer } from '../../core/store';
import { ContextConsumer } from '@lit/context';
import { getSlottedElement, isHTMLMediaElement, listen, querySlot } from '@videojs/utils/dom';
import { Disposer } from '@videojs/utils/events';
import { noop } from '@videojs/utils/function';
import { isNull } from '@videojs/utils/predicate';
/**
* Creates a mixin that consumes a store from context and auto-attaches media elements.
*
* - Requests store from context (must have a provider ancestor)
* - Observes slotted elements for `<video slot="media">` or `<audio slot="media">`
* - Falls back to light DOM children if no shadow root
* - Calls `store.attach(mediaElement)` when found
* - Cleans up on disconnect
*
* @example
* ```ts
* const { StoreAttachMixin } = createStore({ slices: [playbackSlice] });
*
* class MyControls extends StoreAttachMixin(LitElement) {}
* ```
*/
export function createStoreAttachMixin<Slices extends AnySlice[]>(
context: Context<unknown, Store<UnionSliceTarget<Slices>, Slices>>,
): Mixin<ReactiveElement, StoreConsumer<Slices>> {
type ConsumedStore = Store<UnionSliceTarget<Slices>, Slices>;
return <Base extends Constructor<ReactiveElement>>(BaseClass: Base) => {
class StoreAttachElement extends BaseClass implements StoreConsumer<Slices> {
#disposer = new Disposer();
#detach = noop;
#consumer = new ContextConsumer(this, {
context,
callback: () => this.#attachMedia(),
subscribe: false,
});
get store(): ConsumedStore | null {
return this.#consumer.value ?? null;
}
override connectedCallback() {
super.connectedCallback();
const shadow = this.shadowRoot;
if (shadow) {
const slot = querySlot(shadow, 'media');
if (slot) this.#disposer.add(listen(slot, 'slotchange', () => this.#attachMedia()));
}
this.#attachMedia();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.#disposer.dispose();
this.#detach();
}
#attachMedia() {
const store = this.store;
if (isNull(store)) return;
// Check if element is media, or search inside for nested media
const findMedia = (el: Element): HTMLMediaElement | null =>
isHTMLMediaElement(el) ? el : el.querySelector('video, audio');
const media = this.shadowRoot
? getSlottedElement(this.shadowRoot, 'media', findMedia)
: this.querySelector('video, audio');
if (store.target !== media) {
this.#detach();
this.#detach = store.attach(media as UnionSliceTarget<Slices>);
}
}
}
return StoreAttachElement;
};
}
@@ -0,0 +1,41 @@
import type { Context } from '@lit/context';
import type { ReactiveElement } from '@lit/reactive-element';
import type { Constructor, Mixin } from '@videojs/utils/types';
import type { AnySlice, UnionSliceTarget } from '../../core/slice';
import type { Store, StoreProvider } from '../../core/store';
import { createStoreAttachMixin } from './attach-mixin';
import { createStoreProviderMixin } from './provider-mixin';
/**
* Creates a combined mixin that both provides a store and auto-attaches media elements.
*
* Composes `StoreProviderMixin` and `StoreAttachMixin` - the provider mixin provides the store
* via context, and the attach mixin consumes it and auto-attaches media elements.
*
* @example
* ```ts
* const { StoreMixin } = createStore({ slices: [playbackSlice] });
*
* class MyPlayer extends StoreMixin(LitElement) {
* render() {
* return html`<slot></slot>`;
* }
* }
* ```
*/
export function createStoreMixin<Slices extends AnySlice[]>(
context: Context<unknown, Store<UnionSliceTarget<Slices>, Slices>>,
factory: () => Store<UnionSliceTarget<Slices>, Slices>,
): Mixin<ReactiveElement, StoreProvider<Slices>> {
const ProviderMixin = createStoreProviderMixin<Slices>(context, factory);
const AttachMixin = createStoreAttachMixin<Slices>(context);
return <Base extends Constructor<ReactiveElement>>(BaseClass: Base) => {
// ProviderMixin wraps AttachMixin so during connectedCallback:
// 1. ProviderMixin runs first (provides store via context)
// 2. AttachMixin runs second (consumes store from context)
return ProviderMixin(AttachMixin(BaseClass));
};
}
+3
View File
@@ -0,0 +1,3 @@
export { createStoreAttachMixin } from './attach-mixin';
export { createStoreMixin } from './combined-mixin';
export { createStoreProviderMixin } from './provider-mixin';
@@ -0,0 +1,82 @@
import type { Context } from '@lit/context';
import type { ReactiveElement } from '@lit/reactive-element';
import type { Constructor } from '@videojs/utils/types';
import type { AnySlice, UnionSliceTarget } from '../../core/slice';
import type { Store, StoreProvider } from '../../core/store';
import { ContextProvider } from '@lit/context';
import { isNull } from '@videojs/utils/predicate';
/**
* Creates a mixin that provides a store via context.
*
* - Creates a store instance on first access
* - Provides the store to descendants via Lit Context Protocol
* - Allows store replacement via setter (notifies all consumers)
* - Destroys the store on disconnect (if not externally provided)
*
* @example
* ```ts
* const { StoreProviderMixin } = createStore({
* slices: [playbackSlice]
* });
*
* class MyPlayer extends StoreProviderMixin(LitElement) {
* render() {
* return html`<slot></slot>`;
* }
* }
* ```
*/
export function createStoreProviderMixin<Slices extends AnySlice[]>(
context: Context<unknown, Store<UnionSliceTarget<Slices>, Slices>>,
factory: () => Store<UnionSliceTarget<Slices>, Slices>,
): <Base extends Constructor<ReactiveElement>>(BaseClass: Base) => Base & Constructor<StoreProvider<Slices>> {
type ProvidedStore = Store<UnionSliceTarget<Slices>, Slices>;
return <Base extends Constructor<ReactiveElement>>(BaseClass: Base) => {
class StoreProviderElement extends BaseClass implements StoreProvider<Slices> {
#store: ProvidedStore | null = null;
#isOwner = false;
#provider = new ContextProvider(this, {
context,
initialValue: this.store,
});
get store(): ProvidedStore {
if (isNull(this.#store)) {
this.#store = factory();
this.#isOwner = true;
}
return this.#store;
}
set store(newStore: ProvidedStore) {
const wasOwner = this.#isOwner;
const oldStore = this.#store;
this.#store = newStore;
this.#isOwner = false;
if (wasOwner && oldStore && oldStore !== newStore) {
oldStore.destroy();
}
this.#provider.setValue(newStore);
}
override disconnectedCallback() {
super.disconnectedCallback();
if (this.#isOwner && this.#store) {
this.#store.destroy();
this.#store = null;
this.#isOwner = false;
}
}
}
return StoreProviderElement;
};
}
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { createLitTestStore, setupDomCleanup, TestBaseElement, uniqueTag } from '../../tests/test-utils';
setupDomCleanup();
describe('createStoreAttachMixin', () => {
it('exposes store property (initially null without context)', async () => {
const { StoreAttachMixin } = createLitTestStore();
const tagName = uniqueTag('test-attach-standalone');
class TestElement extends StoreAttachMixin(TestBaseElement) {}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
document.body.appendChild(el);
await el.updateComplete;
// Without a context provider ancestor, store is null
expect(el.store).toBeNull();
});
it('can be applied to TestBaseElement', () => {
const { StoreAttachMixin } = createLitTestStore();
class MixedElement extends StoreAttachMixin(TestBaseElement) {}
expect(MixedElement.prototype).toBeInstanceOf(TestBaseElement);
});
});
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest';
import { createLitTestStore, setupDomCleanup, TestBaseElement, uniqueTag } from '../../tests/test-utils';
setupDomCleanup();
// Helper to create shadow root with named media slot
function createShadowWithSlot(el: HTMLElement): void {
const shadow = el.attachShadow({ mode: 'open' });
shadow.innerHTML = '<slot name="media"></slot>';
}
describe('createStoreMixin', () => {
it('provides store and attaches media', async () => {
const { StoreMixin } = createLitTestStore();
const tagName = uniqueTag('test-combined');
class TestElement extends StoreMixin(TestBaseElement) {
override createRenderRoot() {
createShadowWithSlot(this);
return this.shadowRoot!;
}
}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
document.body.appendChild(el);
await el.updateComplete;
expect(el.store).toBeDefined();
expect(el.store.state).toEqual({ volume: 1, muted: false });
});
it('auto-attaches slotted video element', async () => {
const { StoreMixin } = createLitTestStore();
const tagName = uniqueTag('test-auto-attach');
class TestElement extends StoreMixin(TestBaseElement) {
override createRenderRoot() {
createShadowWithSlot(this);
return this.shadowRoot!;
}
}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
const video = document.createElement('video');
video.slot = 'media';
el.appendChild(video);
document.body.appendChild(el);
await el.updateComplete;
// Wait for slotchange
await new Promise(resolve => requestAnimationFrame(resolve));
expect(el.store.target).toBe(video);
});
it('auto-attaches light DOM video when no shadow root', async () => {
const { StoreMixin } = createLitTestStore();
const tagName = uniqueTag('test-light-dom');
class TestElement extends StoreMixin(TestBaseElement) {
override createRenderRoot() {
return this; // Use light DOM
}
}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
const video = document.createElement('video');
el.appendChild(video);
document.body.appendChild(el);
await el.updateComplete;
// Wait for attachment
await new Promise(resolve => requestAnimationFrame(resolve));
expect(el.store.target).toBe(video);
});
it('finds nested video element', async () => {
const { StoreMixin } = createLitTestStore();
const tagName = uniqueTag('test-nested');
class TestElement extends StoreMixin(TestBaseElement) {
override createRenderRoot() {
createShadowWithSlot(this);
return this.shadowRoot!;
}
}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
const wrapper = document.createElement('div');
wrapper.slot = 'media';
const video = document.createElement('video');
wrapper.appendChild(video);
el.appendChild(wrapper);
document.body.appendChild(el);
await el.updateComplete;
// Wait for slotchange
await new Promise(resolve => requestAnimationFrame(resolve));
expect(el.store.target).toBe(video);
});
it('auto-attaches audio element', async () => {
const { StoreMixin } = createLitTestStore();
const tagName = uniqueTag('test-audio');
class TestElement extends StoreMixin(TestBaseElement) {
override createRenderRoot() {
createShadowWithSlot(this);
return this.shadowRoot!;
}
}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
const audio = document.createElement('audio');
audio.slot = 'media';
el.appendChild(audio);
document.body.appendChild(el);
await el.updateComplete;
// Wait for slotchange
await new Promise(resolve => requestAnimationFrame(resolve));
expect(el.store.target).toBe(audio);
});
it('attaches only the first media element when multiple exist', async () => {
const { StoreMixin } = createLitTestStore();
const tagName = uniqueTag('test-multiple-media');
class TestElement extends StoreMixin(TestBaseElement) {
override createRenderRoot() {
createShadowWithSlot(this);
return this.shadowRoot!;
}
}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
const video1 = document.createElement('video');
video1.slot = 'media';
const video2 = document.createElement('video');
video2.slot = 'media';
el.appendChild(video1);
el.appendChild(video2);
document.body.appendChild(el);
await el.updateComplete;
// Wait for slotchange
await new Promise(resolve => requestAnimationFrame(resolve));
// Should attach only the first one
expect(el.store.target).toBe(video1);
});
});
@@ -0,0 +1,114 @@
import { describe, expect, it } from 'vitest';
import { createLitTestStore, setupDomCleanup, TestBaseElement, uniqueTag } from '../../tests/test-utils';
setupDomCleanup();
describe('createStoreProviderMixin', () => {
it('creates store lazily on first access', async () => {
const { StoreProviderMixin } = createLitTestStore();
const tagName = uniqueTag('test-provider');
class TestElement extends StoreProviderMixin(TestBaseElement) {}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
document.body.appendChild(el);
await el.updateComplete;
expect(el.store).toBeDefined();
expect(el.store.state).toEqual({ volume: 1, muted: false });
});
it('reuses same store instance', async () => {
const { StoreProviderMixin } = createLitTestStore();
const tagName = uniqueTag('test-provider-reuse');
class TestElement extends StoreProviderMixin(TestBaseElement) {}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
document.body.appendChild(el);
await el.updateComplete;
const first = el.store;
const second = el.store;
expect(first).toBe(second);
});
it('destroys owned store on disconnect', async () => {
const { StoreProviderMixin } = createLitTestStore();
const tagName = uniqueTag('test-provider-destroy');
class TestElement extends StoreProviderMixin(TestBaseElement) {}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
document.body.appendChild(el);
await el.updateComplete;
const store = el.store;
expect(store.destroyed).toBe(false);
el.remove();
expect(store.destroyed).toBe(true);
});
it('allows setting custom store via setter', async () => {
const { StoreProviderMixin, create } = createLitTestStore();
const tagName = uniqueTag('test-provider-setter');
class TestElement extends StoreProviderMixin(TestBaseElement) {}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
document.body.appendChild(el);
await el.updateComplete;
const customStore = create();
el.store = customStore;
expect(el.store).toBe(customStore);
});
it('does not destroy externally provided store on disconnect', async () => {
const { StoreProviderMixin, create } = createLitTestStore();
const tagName = uniqueTag('test-provider-external');
class TestElement extends StoreProviderMixin(TestBaseElement) {}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
const externalStore = create();
el.store = externalStore;
document.body.appendChild(el);
await el.updateComplete;
el.remove();
// External store should NOT be destroyed
expect(externalStore.destroyed).toBe(false);
});
it('destroys old owned store when setting new store', async () => {
const { StoreProviderMixin, create } = createLitTestStore();
const tagName = uniqueTag('test-provider-replace');
class TestElement extends StoreProviderMixin(TestBaseElement) {}
customElements.define(tagName, TestElement);
const el = document.createElement(tagName) as TestElement;
document.body.appendChild(el);
await el.updateComplete;
const ownedStore = el.store; // Creates owned store
const newStore = create();
el.store = newStore;
// Owned store should be destroyed
expect(ownedStore.destroyed).toBe(true);
expect(newStore.destroyed).toBe(false);
});
});
@@ -0,0 +1,32 @@
import { describe, expectTypeOf, it } from 'vitest';
import { createLitTestStore, TestBaseElement } from '../../tests/test-utils';
describe('mixin types', () => {
it('storeMixin adds store property', () => {
const { StoreMixin } = createLitTestStore();
const _MixedElement = StoreMixin(TestBaseElement);
type Instance = InstanceType<typeof _MixedElement>;
// Verify store property exists on the mixed type
expectTypeOf<Instance>().toHaveProperty('store');
});
it('storeProviderMixin adds store property', () => {
const { StoreProviderMixin } = createLitTestStore();
const _MixedElement = StoreProviderMixin(TestBaseElement);
type Instance = InstanceType<typeof _MixedElement>;
// Verify store property exists on the mixed type
expectTypeOf<Instance>().toHaveProperty('store');
});
it('storeAttachMixin adds store property', () => {
const { StoreAttachMixin } = createLitTestStore();
const _MixedElement = StoreAttachMixin(TestBaseElement);
type Instance = InstanceType<typeof _MixedElement>;
// Verify store property exists on the mixed type
expectTypeOf<Instance>().toHaveProperty('store');
});
});
@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest';
import { createSlice } from '../../core/slice';
import { createStore } from '../create-store';
import { TestBaseElement } from './test-utils';
describe('createStore', () => {
// Mock target
class MockMedia extends EventTarget {
volume = 1;
muted = false;
}
const audioSlice = createSlice<MockMedia>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({
volume: target.volume,
muted: target.muted,
}),
subscribe: ({ target, update, signal }) => {
const handler = () => update();
target.addEventListener('volumechange', handler);
signal.addEventListener('abort', () => {
target.removeEventListener('volumechange', handler);
});
},
request: {
setVolume: (volume: number, { target }) => {
target.volume = volume;
target.dispatchEvent(new Event('volumechange'));
return volume;
},
},
});
describe('create', () => {
it('creates a store instance', () => {
const { create } = createStore({ slices: [audioSlice] });
const store = create();
expect(store).toBeDefined();
expect(store.state).toEqual({ volume: 1, muted: false });
});
it('creates independent store instances', () => {
const { create } = createStore({ slices: [audioSlice] });
const store1 = create();
const store2 = create();
expect(store1).not.toBe(store2);
});
});
describe('context', () => {
it('contexts share the same key for interoperability', () => {
const result1 = createStore({ slices: [audioSlice] });
const result2 = createStore({ slices: [audioSlice] });
// Contexts use a shared key so different store configurations can interoperate
expect(result1.context).toBe(result2.context);
});
it('context is defined', () => {
const { context } = createStore({ slices: [audioSlice] });
expect(context).toBeDefined();
});
});
describe('mixins', () => {
it('returns StoreMixin', () => {
const { StoreMixin } = createStore({ slices: [audioSlice] });
expect(typeof StoreMixin).toBe('function');
});
it('returns StoreProviderMixin', () => {
const { StoreProviderMixin } = createStore({ slices: [audioSlice] });
expect(typeof StoreProviderMixin).toBe('function');
});
it('returns StoreAttachMixin', () => {
const { StoreAttachMixin } = createStore({ slices: [audioSlice] });
expect(typeof StoreAttachMixin).toBe('function');
});
it('mixins can be applied to TestBaseElement', () => {
const { StoreMixin, StoreProviderMixin, StoreAttachMixin } = createStore({ slices: [audioSlice] });
const Mixed1 = StoreMixin(TestBaseElement);
const Mixed2 = StoreProviderMixin(TestBaseElement);
const Mixed3 = StoreAttachMixin(TestBaseElement);
expect(Mixed1.prototype).toBeInstanceOf(TestBaseElement);
expect(Mixed2.prototype).toBeInstanceOf(TestBaseElement);
expect(Mixed3.prototype).toBeInstanceOf(TestBaseElement);
});
});
describe('result object', () => {
it('returns all expected properties', () => {
const result = createStore({ slices: [audioSlice] });
expect(result).toHaveProperty('StoreMixin');
expect(result).toHaveProperty('StoreProviderMixin');
expect(result).toHaveProperty('StoreAttachMixin');
expect(result).toHaveProperty('context');
expect(result).toHaveProperty('create');
});
});
});
+163
View File
@@ -0,0 +1,163 @@
import type { ReactiveControllerHost } from '@lit/reactive-element';
import type { AnySlice } from '../../core/slice';
import type { Store } from '../../core/store';
import { ReactiveElement } from '@lit/reactive-element';
import { noop } from '@videojs/utils/function';
import { afterEach } from 'vitest';
import { createSlice } from '../../core/slice';
import { createStore as createCoreStore } from '../../core/store';
import { createStore as createLitStore } from '../create-store';
/** Concrete base class for mixin tests (ReactiveElement is abstract). */
export class TestBaseElement extends ReactiveElement {}
export class MockMedia extends EventTarget {
volume = 1;
muted = false;
}
export const audioSlice = createSlice<MockMedia>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({
volume: target.volume,
muted: target.muted,
}),
subscribe: ({ target, update, signal }) => {
const handler = () => update();
target.addEventListener('volumechange', handler);
signal.addEventListener('abort', () => {
target.removeEventListener('volumechange', handler);
});
},
request: {
setVolume: (volume: number, { target }): number => {
target.volume = volume;
target.dispatchEvent(new Event('volumechange'));
return volume;
},
setMuted: (muted: boolean, { target }): boolean => {
target.muted = muted;
target.dispatchEvent(new Event('volumechange'));
return muted;
},
slowSetVolume: async (volume: number, { target }): Promise<number> => {
await new Promise(resolve => setTimeout(resolve, 50));
target.volume = volume;
target.dispatchEvent(new Event('volumechange'));
return volume;
},
},
});
/** Slice with custom keys (name !== key) for testing superseding behavior. */
export const customKeySlice = createSlice<MockMedia>()({
initialState: { volume: 1, muted: false },
getSnapshot: ({ target }) => ({
volume: target.volume,
muted: target.muted,
}),
subscribe: ({ target, update, signal }) => {
const handler = () => update();
target.addEventListener('volumechange', handler);
signal.addEventListener('abort', () => {
target.removeEventListener('volumechange', handler);
});
},
request: {
// name='adjustVolume', key='audio-settings'
adjustVolume: {
key: 'audio-settings',
handler: async (volume: number, { target }): Promise<number> => {
await new Promise(resolve => setTimeout(resolve, 20));
target.volume = volume;
target.dispatchEvent(new Event('volumechange'));
return volume;
},
},
// name='toggleMute', key='audio-settings' (same key - will supersede adjustVolume)
toggleMute: {
key: 'audio-settings',
handler: async (muted: boolean, { target }): Promise<boolean> => {
await new Promise(resolve => setTimeout(resolve, 20));
target.muted = muted;
target.dispatchEvent(new Event('volumechange'));
return muted;
},
},
},
});
type TestSlice = typeof audioSlice;
type CustomKeySlice = typeof customKeySlice;
// For controller tests - creates core store with attached target
export function createCoreTestStore(): { store: Store<MockMedia, [TestSlice]>; target: MockMedia } {
const store = createCoreStore({
slices: [audioSlice] as [AnySlice],
onError: noop,
});
const target = new MockMedia();
store.attach(target);
return { store, target };
}
/** Creates store with custom key slice (name !== key) for testing superseding. */
export function createCustomKeyTestStore(): { store: Store<MockMedia, [CustomKeySlice]>; target: MockMedia } {
const store = createCoreStore({
slices: [customKeySlice] as [AnySlice],
onError: noop,
});
const target = new MockMedia();
store.attach(target);
return { store, target };
}
// For mixin tests - creates lit store factory
export function createLitTestStore() {
return createLitStore({ slices: [audioSlice] });
}
// Mock ReactiveControllerHost for controller tests
export interface MockHost extends ReactiveControllerHost {
controllers: Set<unknown>;
updateCount: number;
}
export function createMockHost(): MockHost {
const controllers = new Set<unknown>();
const host: MockHost = {
controllers,
updateCount: 0,
addController(controller: unknown): void {
controllers.add(controller);
},
removeController(controller: unknown): void {
controllers.delete(controller);
},
requestUpdate(): void {
host.updateCount++;
},
updateComplete: Promise.resolve(true),
};
return host;
}
// For mixin tests - unique custom element tags
let tagCounter = 0;
export function uniqueTag(base: string): string {
return `${base}-${Date.now()}-${tagCounter++}`;
}
export function setupDomCleanup(): void {
afterEach(() => {
document.body.innerHTML = '';
});
}