mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
feat(element): add lightweight reactive element base (#513)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare const __DEV__: boolean;
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './controllers';
|
||||
export * from './store-accessor';
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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 = '';
|
||||
});
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user