feat(element): add lightweight reactive element base (#513)

This commit is contained in:
rahim
2026-02-13 00:41:49 +11:00
committed by GitHub
parent f2efa2456b
commit 33b21906cd
60 changed files with 1793 additions and 457 deletions
@@ -0,0 +1,8 @@
export {
StoreController,
type StoreControllerHost,
} from './store-controller';
export {
SubscriptionController,
type SubscriptionControllerHost,
} from './subscription-controller';
@@ -0,0 +1,116 @@
import type { ReactiveController, ReactiveControllerHost } from '@videojs/element';
import { noop } from '@videojs/utils/function';
import { isNull, isUndefined } from '@videojs/utils/predicate';
import { shallowEqual } from '../../core/shallow-equal';
import type { AnyStore, InferStoreState } from '../../core/store';
import { StoreAccessor, type StoreSource } from '../store-accessor';
export type StoreControllerHost = ReactiveControllerHost & HTMLElement;
export type Selector<State, Result> = (state: State) => Result;
/**
* Access store state and actions.
*
* Without selector: Returns the store, does NOT subscribe to changes.
* With selector: Returns selected state, triggers update when selected state changes (shallowEqual).
*
* @example
* ```ts
* // Store access (no subscription) - access actions
* class Controls extends LitElement {
* #store = new StoreController(this, storeSource);
*
* handleClick() {
* this.#store.value.setVolume(0.5);
* }
* }
*
* // Selector-based subscription - re-renders when playback changes
* class PlayButton extends LitElement {
* #playback = new StoreController(this, storeSource, selectPlayback);
*
* render() {
* const playback = this.#playback.value;
* if (!playback) return nothing;
* return html`<button @click=${playback.toggle}>
* ${playback.paused ? 'Play' : 'Pause'}
* </button>`;
* }
* }
* ```
*/
export class StoreController<Store extends AnyStore, Result = Store> implements ReactiveController {
readonly #host: StoreControllerHost;
readonly #selector: Selector<InferStoreState<Store>, Result> | undefined;
readonly #accessor: StoreAccessor<Store>;
#cached: Result | undefined;
#unsubscribe = noop;
constructor(host: StoreControllerHost, source: StoreSource<Store>);
constructor(
host: StoreControllerHost,
source: StoreSource<Store>,
selector: Selector<InferStoreState<Store>, Result>
);
constructor(
host: StoreControllerHost,
source: StoreSource<Store>,
selector?: Selector<InferStoreState<Store>, Result>
) {
this.#host = host;
this.#selector = selector;
this.#accessor = new StoreAccessor(host, source, (store) => this.#connect(store));
host.addController(this);
}
get value(): Result {
const store = this.#accessor.value;
if (isNull(store)) {
throw new Error('Store not available');
}
// Without selector: return store
if (isUndefined(this.#selector)) {
return store as unknown as Result;
}
// With selector: return cached selected value
this.#cached ??= this.#selector(store.state as InferStoreState<Store>);
return this.#cached;
}
hostDisconnected(): void {
this.#unsubscribe();
this.#unsubscribe = noop;
this.#cached = undefined;
}
#connect(store: Store): void {
this.#unsubscribe();
// Without selector: no subscription
if (isUndefined(this.#selector)) {
return;
}
// With selector: subscribe with shallowEqual comparison
const selector = this.#selector;
this.#cached = selector(store.state as InferStoreState<Store>);
this.#unsubscribe = store.subscribe(() => {
const next = selector(store.state as InferStoreState<Store>);
if (!shallowEqual(this.#cached, next)) {
this.#cached = next;
this.#host.requestUpdate();
}
});
}
}
export namespace StoreController {
export type Host = StoreControllerHost;
}
@@ -0,0 +1,83 @@
import type { ReactiveController, ReactiveControllerHost } from '@videojs/element';
import { noop } from '@videojs/utils/function';
import { isNull } from '@videojs/utils/predicate';
import type { AnyStore } from '../../core/store';
import { StoreAccessor, type StoreSource } from '../store-accessor';
export type SubscriptionControllerHost = ReactiveControllerHost & HTMLElement;
export interface SubscriptionControllerConfig<Store extends AnyStore, Value> {
getValue: (store: Store) => Value;
subscribe: (store: Store, onChange: () => void) => () => void;
}
/**
* Resolves a store from context or direct source and manages subscription lifecycle.
*
* Combines store resolution (direct or context) with subscription management.
* Use as a building block for controllers that need store access with subscriptions.
*
* @example
* ```ts
* class MyController<Store extends AnyStore> {
* #ctrl: SubscriptionController<Store, Tasks>;
*
* constructor(host: Host, source: StoreSource<Store>) {
* this.#ctrl = new SubscriptionController(host, source, {
* subscribe: (store, onChange) => store.queue.subscribe(onChange),
* getValue: (store) => store.queue.tasks,
* });
* }
*
* get value() {
* return this.#ctrl.value;
* }
* }
* ```
*/
export class SubscriptionController<Store extends AnyStore, Value> implements ReactiveController {
readonly #host: SubscriptionControllerHost;
readonly #config: SubscriptionControllerConfig<Store, Value>;
readonly #accessor: StoreAccessor<Store>;
#unsubscribe = noop;
constructor(
host: SubscriptionControllerHost,
source: StoreSource<Store>,
config: SubscriptionControllerConfig<Store, Value>
) {
this.#host = host;
this.#config = config;
this.#accessor = new StoreAccessor(host, source, (store) => this.#connect(store));
host.addController(this);
}
get value(): Value {
const store = this.#accessor.value;
if (isNull(store)) {
throw new Error('Store not available');
}
return this.#config.getValue(store);
}
hostDisconnected(): void {
this.#unsubscribe();
this.#unsubscribe = noop;
}
#connect(store: Store): void {
this.#unsubscribe();
this.#unsubscribe = this.#config.subscribe(store, () => {
this.#host.requestUpdate();
});
}
}
export namespace SubscriptionController {
export type Host = SubscriptionControllerHost;
export type Config<Store extends AnyStore, Value> = SubscriptionControllerConfig<Store, Value>;
}
@@ -0,0 +1,98 @@
import { afterEach, describe, expect, it } from 'vitest';
import { createCoreTestStore, createTestHost } from '../../tests/test-utils';
import { StoreController } from '../store-controller';
describe('StoreController', () => {
afterEach(() => {
document.body.innerHTML = '';
});
it('returns store without selector (no subscription)', () => {
const { store } = createCoreTestStore();
const host = createTestHost();
const controller = new StoreController(host, store);
const value = controller.value;
expect(value).toBe(store);
expect(value.volume).toBe(1);
expect(typeof value.setVolume).toBe('function');
});
it('does not trigger updates without selector', async () => {
const { store } = createCoreTestStore();
const host = createTestHost();
new StoreController(host, store);
document.body.appendChild(host);
// Wait for initial update cycle to complete
await Promise.resolve();
const initialCount = host.updateCount;
await store.setVolume(0.5);
expect(host.updateCount).toBe(initialCount);
});
it('returns selected state with selector', () => {
const { store } = createCoreTestStore();
const host = createTestHost();
const controller = new StoreController(host, store, (s) => s.volume);
document.body.appendChild(host);
expect(controller.value).toBe(1);
});
it('updates when selected state changes', async () => {
const { store } = createCoreTestStore();
const host = createTestHost();
const controller = new StoreController(host, store, (s) => s.volume);
document.body.appendChild(host);
expect(controller.value).toBe(1);
await store.setVolume(0.5);
expect(controller.value).toBe(0.5);
expect(host.updateCount).toBeGreaterThan(0);
});
it('unsubscribes on disconnect', async () => {
const { store } = createCoreTestStore();
const host = createTestHost();
new StoreController(host, store, (s) => s.volume);
document.body.appendChild(host);
host.remove();
const updateCountBefore = host.updateCount;
await store.setVolume(0.5);
expect(host.updateCount).toBe(updateCountBefore);
});
it('syncs to current state on reconnect', async () => {
const { store } = createCoreTestStore();
const host = createTestHost();
const controller = new StoreController(host, store, (s) => s.volume);
document.body.appendChild(host);
await store.setVolume(0.5);
expect(controller.value).toBe(0.5);
host.remove();
await store.setVolume(0.8);
// Reconnect
document.body.appendChild(host);
expect(controller.value).toBe(0.8);
});
});
+1
View File
@@ -0,0 +1 @@
declare const __DEV__: boolean;
+2
View File
@@ -0,0 +1,2 @@
export * from './controllers';
export * from './store-accessor';
+71
View File
@@ -0,0 +1,71 @@
import type { ReactiveController, ReactiveControllerHost } from '@videojs/element';
import type { Context } from '@videojs/element/context';
import { ContextConsumer } from '@videojs/element/context';
import { noop } from '@videojs/utils/function';
import type { AnyStore } from '../core/store';
import { isStore } from '../core/store';
export type StoreSource<Store extends AnyStore> = Store | Context<unknown, Store>;
export type StoreAccessorHost = ReactiveControllerHost & HTMLElement;
/**
* Resolves a store from either a direct instance or context.
*
* When given a direct store, provides immediate access.
* When given a context, sets up a ContextConsumer to receive the store.
*
* @example Direct store
* ```ts
* const accessor = new StoreAccessor(host, store, (s) => console.log('available', s));
* accessor.value; // Store (immediately available)
* ```
*
* @example Context source
* ```ts
* const accessor = new StoreAccessor(host, context, (s) => console.log('available', s));
* accessor.value; // null until context provides store
* ```
*/
export class StoreAccessor<Store extends AnyStore> implements ReactiveController {
readonly #onAvailable: (store: Store) => void;
readonly #consumer: ContextConsumer<Context<unknown, Store>, StoreAccessorHost> | null;
#directStore: Store | null;
constructor(host: StoreAccessorHost, source: StoreSource<Store>, onAvailable?: (store: Store) => void) {
this.#onAvailable = onAvailable ?? noop;
// Check if source is a store (object with subscribe) or context (symbol/string)
if (isStore(source)) {
this.#directStore = source as Store;
this.#consumer = null;
} else {
this.#directStore = null;
this.#consumer = new ContextConsumer(host, {
context: source,
callback: (store) => this.#onAvailable(store),
subscribe: false,
});
}
host.addController(this);
}
/** Returns the store, or null if not yet available from context. */
get value(): Store | null {
if (this.#consumer) {
return this.#consumer.value ?? null;
}
return this.#directStore;
}
hostConnected(): void {
// For direct store, trigger onAvailable on connect/reconnect
// Context consumer handles its own reconnect via callback
if (this.#directStore) {
this.#onAvailable(this.#directStore);
}
}
}
@@ -0,0 +1,75 @@
import { describe, expect, it, vi } from 'vitest';
import { StoreAccessor } from '../store-accessor';
import { createCoreTestStore, createTestHost } from './test-utils';
describe('StoreAccessor', () => {
describe('direct store source', () => {
it('returns store immediately', () => {
const { store } = createCoreTestStore();
const host = createTestHost();
const accessor = new StoreAccessor(host, store);
expect(accessor.value).toBe(store);
});
it('does not call onAvailable on construction', () => {
const { store } = createCoreTestStore();
const host = createTestHost();
const onAvailable = vi.fn();
// For direct store, onAvailable is NOT called on construction
// It's called on hostConnected instead
const _accessor = new StoreAccessor(host, store, onAvailable);
expect(onAvailable).not.toHaveBeenCalled();
expect(_accessor).toBeDefined();
});
it('calls onAvailable on hostConnected', () => {
const { store } = createCoreTestStore();
const host = createTestHost();
const onAvailable = vi.fn();
const accessor = new StoreAccessor(host, store, onAvailable);
accessor.hostConnected();
expect(onAvailable).toHaveBeenCalledWith(store);
expect(onAvailable).toHaveBeenCalledTimes(1);
});
it('calls onAvailable on each reconnect', () => {
const { store } = createCoreTestStore();
const host = createTestHost();
const onAvailable = vi.fn();
const accessor = new StoreAccessor(host, store, onAvailable);
// First connect
accessor.hostConnected();
expect(onAvailable).toHaveBeenCalledTimes(1);
// Simulate reconnect
accessor.hostConnected();
expect(onAvailable).toHaveBeenCalledTimes(2);
});
});
describe('context source', () => {
it('returns null when context not yet provided', () => {
const host = createTestHost();
// Use a symbol as context (this is what createContext returns)
const fakeContext = Symbol('test-context') as any;
const accessor = new StoreAccessor(host, fakeContext);
expect(accessor.value).toBeNull();
});
// Note: Testing actual context provider behavior requires
// a full DOM hierarchy with a provider element, which is
// tested in create-store.test.ts integration tests
});
});
+108
View File
@@ -0,0 +1,108 @@
import { ReactiveElement } from '@videojs/element';
import { noop } from '@videojs/utils/function';
import { afterEach } from 'vitest';
import { defineSlice } from '../../core/slice';
import type { Store } from '../../core/store';
import { createStore as createCoreStore } from '../../core/store';
/** Concrete base class for mixin tests (ReactiveElement is abstract). */
export class TestBaseElement extends ReactiveElement {}
/**
* Test host element that extends ReactiveElement.
* Tracks update calls for assertions.
*/
export class TestHostElement extends ReactiveElement {
updateCount = 0;
requestUpdate(): void {
this.updateCount++;
super.requestUpdate();
}
}
export class MockMedia extends EventTarget {
volume = 1;
muted = false;
}
export const audioSlice = defineSlice<MockMedia>()({
state: ({ target }) => ({
volume: 1,
muted: false,
setVolume(volume: number) {
target().volume = volume;
target().dispatchEvent(new Event('volumechange'));
return volume;
},
setMuted(muted: boolean) {
target().muted = muted;
target().dispatchEvent(new Event('volumechange'));
return muted;
},
async slowSetVolume(volume: number) {
await new Promise((resolve) => setTimeout(resolve, 50));
target().volume = volume;
target().dispatchEvent(new Event('volumechange'));
return volume;
},
}),
attach({ target, signal, set }) {
const sync = () => set({ volume: target.volume, muted: target.muted });
sync();
target.addEventListener('volumechange', sync);
signal.addEventListener('abort', () => {
target.removeEventListener('volumechange', sync);
});
},
});
export type AudioSliceState = {
volume: number;
muted: boolean;
setVolume: (volume: number) => number;
setMuted: (muted: boolean) => boolean;
slowSetVolume: (volume: number) => Promise<number>;
};
type TestStore = Store<MockMedia, AudioSliceState>;
// For controller tests - creates core store with attached target
export function createCoreTestStore(): { store: TestStore; target: MockMedia } {
const store = createCoreStore<MockMedia>()(audioSlice, { onError: noop });
const target = new MockMedia();
store.attach(target);
return { store, target };
}
/** Type alias for test host. */
export type TestHost = TestHostElement;
let testHostCounter = 0;
/** Creates a test host element for controller tests. */
export function createTestHost(): TestHost {
const tagName = `test-host-${testHostCounter++}`;
if (!customElements.get(tagName)) {
customElements.define(tagName, class extends TestHostElement {});
}
return document.createElement(tagName) as TestHost;
}
// 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 = '';
});
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"declarationDir": "../../types/html"
},
"references": [{ "path": "../.." }, { "path": "../../../element" }],
"include": ["./**/*.ts"]
}