diff --git a/.claude/plans/player-api-implementation.md b/.claude/plans/player-api-implementation.md index 0d03fe73..0ec379f4 100644 --- a/.claude/plans/player-api-implementation.md +++ b/.claude/plans/player-api-implementation.md @@ -6,13 +6,13 @@ Parent issue: [#320](https://github.com/videojs/v10/issues/320) ## Phases -| # | Issue | Title | SP | -|---|-------|-------|-----| -| 1 | [#365](https://github.com/videojs/v10/issues/365) | Store v2 Foundation | 5 | -| 2 | [#366](https://github.com/videojs/v10/issues/366) | PlayerTarget & Features | 3 | -| 3 | [#367](https://github.com/videojs/v10/issues/367) | React Player API | 3 | -| 4 | [#368](https://github.com/videojs/v10/issues/368) | HTML Player API | 5 | -| 5 | [#369](https://github.com/videojs/v10/issues/369) | Cleanup & Documentation | 2 | +| # | Issue | Title | SP | Status | +|---|-------|-------|-----|--------| +| 1 | [#365](https://github.com/videojs/v10/issues/365) | Store v2 Foundation | 5 | ✅ | +| 2 | [#366](https://github.com/videojs/v10/issues/366) | PlayerTarget & Features | 3 | ✅ | +| 3 | [#367](https://github.com/videojs/v10/issues/367) | React Player API | 3 | ✅ | +| 4 | [#368](https://github.com/videojs/v10/issues/368) | HTML Player API | 5 | ✅ | +| 5 | [#369](https://github.com/videojs/v10/issues/369) | Cleanup & Documentation | 2 | 🔄 Partial (store/lit cleanup done in #368) | **Total: 18 SP** diff --git a/packages/core/src/dom/types.ts b/packages/core/src/dom/types.ts index 610a732c..5b1179ba 100644 --- a/packages/core/src/dom/types.ts +++ b/packages/core/src/dom/types.ts @@ -16,3 +16,5 @@ export type PlayerFeature = Slice; export type AnyPlayerFeature = AnySlice; export type PlayerStore = Store>; + +export type AnyPlayerStore = Store; diff --git a/packages/html/package.json b/packages/html/package.json index b427c8c9..2cf8a454 100644 --- a/packages/html/package.json +++ b/packages/html/package.json @@ -16,8 +16,31 @@ ".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./player/*": { + "types": "./dist/define/player/*.d.ts", + "default": "./dist/define/player/*.js" + }, + "./ui/*": { + "types": "./dist/define/ui/*.d.ts", + "default": "./dist/define/ui/*.js" + }, + "./skin/*": { + "types": "./dist/define/skin/*.d.ts", + "default": "./dist/define/skin/*.js" + }, + "./feature/*": { + "types": "./dist/define/feature/*.d.ts", + "default": "./dist/define/feature/*.js" + }, + "./media/*": { + "types": "./dist/define/media/*.d.ts", + "default": "./dist/define/media/*.js" } }, + "sideEffects": [ + "./dist/define/**/*.js" + ], "main": "dist/index.js", "module": "dist/index.js", "types": "dist/index.d.ts", @@ -28,18 +51,22 @@ "build": "tsdown", "build:watch": "tsdown --watch --silent", "dev": "pnpm run build:watch", - "test": "echo \"No tests yet\"", + "test": "vitest run", "clean": "rm -rf dist types" }, "dependencies": { + "@lit/context": "^1.1.0", "@lit/reactive-element": "^2.1.2", "@videojs/core": "workspace:*", "@videojs/store": "workspace:*", "@videojs/utils": "workspace:*" }, "devDependencies": { + "@testing-library/dom": "^10.4.0", + "happy-dom": "^18.0.1", "tsdown": "^0.15.12", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.2.4" }, "publishConfig": { "access": "public" diff --git a/packages/html/src/define/feature/video.ts b/packages/html/src/define/feature/video.ts new file mode 100644 index 00000000..a24a13cf --- /dev/null +++ b/packages/html/src/define/feature/video.ts @@ -0,0 +1 @@ +// TODO: register video features here when registrations are added diff --git a/packages/html/src/define/media/hls-video.ts b/packages/html/src/define/media/hls-video.ts new file mode 100644 index 00000000..f258e8a0 --- /dev/null +++ b/packages/html/src/define/media/hls-video.ts @@ -0,0 +1 @@ +// TODO: Register HLS video element diff --git a/packages/html/src/define/player/video.ts b/packages/html/src/define/player/video.ts new file mode 100644 index 00000000..7bb7b97b --- /dev/null +++ b/packages/html/src/define/player/video.ts @@ -0,0 +1,17 @@ +import { features } from '@videojs/core/dom'; +import { MediaElement } from '@/ui/media-element'; +import { createPlayer } from '../../player/create-player'; + +const { PlayerMixin } = createPlayer({ + features: [...features.video], +}); + +export class VideoPlayer extends PlayerMixin(MediaElement) {} + +customElements.define('video-player', VideoPlayer); + +declare global { + interface HTMLElementTagNameMap { + 'video-player': VideoPlayer; + } +} diff --git a/packages/html/src/define/skin/video.ts b/packages/html/src/define/skin/video.ts new file mode 100644 index 00000000..55b74be0 --- /dev/null +++ b/packages/html/src/define/skin/video.ts @@ -0,0 +1 @@ +// TODO: Implement VideoSkinElement and then register it here diff --git a/packages/html/src/define/ui/play-button.ts b/packages/html/src/define/ui/play-button.ts new file mode 100644 index 00000000..d685caf4 --- /dev/null +++ b/packages/html/src/define/ui/play-button.ts @@ -0,0 +1 @@ +// TODO: Implement PlayButtonElement then register it here diff --git a/packages/html/src/index.ts b/packages/html/src/index.ts index ae126067..fc94a957 100644 --- a/packages/html/src/index.ts +++ b/packages/html/src/index.ts @@ -1,26 +1,18 @@ -// Re-exports from core/dom -export { - type AnyPlayerFeature, - type BufferState, - definePlayerFeature, - type FeatureAvailability, - features, - type Media, - type MediaContainer, - type PlaybackState, - type PlayerFeature, - type PlayerStore, - type PlayerTarget, - type SourceState, - selectBuffer, - selectPlayback, - selectSource, - selectTime, - selectVolume, - type TimeState, - type VolumeState, -} from '@videojs/core/dom'; +// Core +export * from '@videojs/core/dom'; -// Re-exports from store (utilities) +// Store export type { Comparator, Selector } from '@videojs/store'; export { createSelector, shallowEqual } from '@videojs/store'; +// Player +export * from './player/context'; +export * from './player/create-player'; +export * from './player/player-controller'; +export * from './player/player-mixin'; +export * from './store/container-mixin'; +export * from './store/provider-mixin'; +// Store bindings +export * from './store/types'; + +// Primitives +export { MediaElement } from './ui/media-element'; diff --git a/packages/html/src/media/.gitkeep b/packages/html/src/media/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/packages/html/src/player/context.ts b/packages/html/src/player/context.ts new file mode 100644 index 00000000..35d4548b --- /dev/null +++ b/packages/html/src/player/context.ts @@ -0,0 +1,16 @@ +import { type Context, createContext } from '@lit/context'; +import type { AnyPlayerStore, Media, PlayerStore } from '@videojs/core/dom'; + +export const PLAYER_CONTEXT_KEY = Symbol('@videojs/player'); + +export interface PlayerContextValue { + store: Store; + media: Media | null; +} + +export type PlayerContext = Context< + typeof PLAYER_CONTEXT_KEY, + PlayerContextValue +>; + +export const playerContext = createContext(PLAYER_CONTEXT_KEY); diff --git a/packages/html/src/player/create-player.ts b/packages/html/src/player/create-player.ts new file mode 100644 index 00000000..c6cba892 --- /dev/null +++ b/packages/html/src/player/create-player.ts @@ -0,0 +1,91 @@ +import type { AnyPlayerFeature, PlayerStore, PlayerTarget } from '@videojs/core/dom'; +import { combine, createStore } from '@videojs/store'; + +import { type ContainerMixin, createContainerMixin } from '../store/container-mixin'; +import { createProviderMixin, type ProviderMixin } from '../store/provider-mixin'; +import { type PlayerContext, playerContext } from './context'; +import { PlayerController } from './player-controller'; +import { createPlayerMixin, type PlayerMixin } from './player-mixin'; + +export interface CreatePlayerConfig { + features: Features; +} + +export interface CreatePlayerResult { + /** Context for consuming player in controllers. */ + context: PlayerContext; + + /** Creates a store instance for imperative access. */ + create: () => Store; + + /** Player controller bound to this player's context. */ + PlayerController: PlayerController.Constructor; + + /** Mixin for a complete player element (provider + container). */ + PlayerMixin: PlayerMixin; + + /** Mixin that provides player context to descendants. */ + ProviderMixin: ProviderMixin; + + /** Mixin that consumes player context and auto-attaches media elements. */ + ContainerMixin: ContainerMixin; +} + +/** + * Creates a player factory with typed store, mixins, and controller. + * + * @example + * ```ts + * import { features } from '@videojs/core/dom'; + * + * const { + * context, + * create, + * PlayerController, + * PlayerMixin, + * ProviderMixin, + * ContainerMixin, + * } = createPlayer({ + * features: [...features.video], + * }); + * + * // Complete player element + * class VideoPlayer extends PlayerMixin(ReactiveElement) {} + * + * // Or separate provider/container + * class MyPlayer extends ProviderMixin(ReactiveElement) {} + * class MyContainer extends ContainerMixin(ReactiveElement) {} + * + * // Control element + * class PlayButton extends ReactiveElement { + * #playback = new PlayerController(this, selectPlayback); + * } + * ``` + */ +export function createPlayer( + config: CreatePlayerConfig +): CreatePlayerResult> { + type Store = PlayerStore; + + const slice = combine(...config.features); + + function create(): Store { + return createStore()(slice); + } + + const ctx = playerContext as PlayerContext; + + const PlayerMixin = createPlayerMixin(ctx, create); + const ProviderMixin = createProviderMixin(ctx, create); + const ContainerMixin = createContainerMixin(ctx); + const BoundPlayerController = PlayerController as unknown as PlayerController.Constructor; + + return { + context: ctx, + create, + PlayerController: BoundPlayerController, + PlayerMixin, + ProviderMixin, + ContainerMixin, + }; +} diff --git a/packages/html/src/player/player-controller.ts b/packages/html/src/player/player-controller.ts new file mode 100644 index 00000000..a2fdc42f --- /dev/null +++ b/packages/html/src/player/player-controller.ts @@ -0,0 +1,101 @@ +import { ContextConsumer } from '@lit/context'; +import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; +import type { PlayerStore } from '@videojs/core/dom'; +import type { InferStoreState, Selector } from '@videojs/store'; +import { StoreController } from '@videojs/store/lit'; +import type { Constructor as Ctor } from '@videojs/utils/types'; + +import type { PlayerContext, PlayerContextValue } from './context'; + +export type PlayerControllerHost = ReactiveControllerHost & HTMLElement; + +/** + * Reactive controller for accessing player store state. + * + * Without selector: Returns the store, does NOT subscribe to changes. + * With selector: Returns selected state, subscribes with shallowEqual comparison. + * + * @example + * ```ts + * // Store access (no subscription) + * class Controls extends ReactiveElement { + * #player = new PlayerController(this, playerContext); + * + * handleClick() { + * this.#player.value.setVolume(0.5); + * } + * } + * + * // Selector-based subscription + * class PlayButton extends ReactiveElement { + * #playback = new PlayerController(this, playerContext, selectPlayback); + * } + * ``` + */ +export class PlayerController implements ReactiveController { + readonly #host: PlayerControllerHost; + readonly #selector: Selector, Result> | undefined; + + #consumer: ContextConsumer, PlayerControllerHost>; + #storeCtrl: StoreController | null = null; + + constructor(host: PlayerControllerHost, context: PlayerContext); + constructor( + host: PlayerControllerHost, + context: PlayerContext, + selector: Selector, Result> + ); + constructor( + host: PlayerControllerHost, + context: PlayerContext, + selector?: Selector, Result> + ) { + this.#host = host; + this.#selector = selector; + + this.#consumer = new ContextConsumer(host, { + context, + callback: (ctx) => this.#connect(ctx), + subscribe: true, + }); + + host.addController(this); + } + + get value(): Result { + const ctx = this.#consumer.value; + if (!ctx) throw new Error('Player context not available'); + + // Without selector: return store directly + if (!this.#selector) return ctx.store as unknown as Result; + + // With selector: use StoreController + return this.#storeCtrl!.value; + } + + hostConnected(): void { + const ctx = this.#consumer.value; + if (ctx) this.#connect(ctx); + } + + hostDisconnected(): void { + this.#storeCtrl = null; + } + + #connect(ctx: PlayerContextValue | undefined): void { + if (!ctx) return; + + // Create StoreController with the store directly + if (!this.#storeCtrl && this.#selector) { + this.#storeCtrl = new StoreController(this.#host, ctx.store, this.#selector); + } + } +} + +export namespace PlayerController { + export type Host = PlayerControllerHost; + + export type Constructor = Ctor< + typeof PlayerController + >; +} diff --git a/packages/html/src/player/player-mixin.ts b/packages/html/src/player/player-mixin.ts new file mode 100644 index 00000000..a9408885 --- /dev/null +++ b/packages/html/src/player/player-mixin.ts @@ -0,0 +1,33 @@ +import type { ReactiveElement } from '@lit/reactive-element'; +import type { PlayerStore } from '@videojs/core/dom'; +import type { Constructor } from '@videojs/utils/types'; + +import { createContainerMixin } from '../store/container-mixin'; +import { createProviderMixin } from '../store/provider-mixin'; +import type { PlayerProvider } from '../store/types'; +import type { PlayerContext } from './context'; + +export interface PlayerElement extends PlayerProvider {} + +type Base = Constructor; + +type Result = Class & PlayerElement; + +export type PlayerMixin = (BaseClass: Class) => Result; + +/** + * Creates a mixin that combines provider and container functionality. + * + * Use for a complete player element that owns the store and attaches media. + */ +export function createPlayerMixin( + context: PlayerContext, + factory: () => Store +): PlayerMixin { + const ProviderMixin = createProviderMixin(context, factory); + const ContainerMixin = createContainerMixin(context); + + return (BaseClass: Class) => { + return ContainerMixin(ProviderMixin(BaseClass)) as unknown as Result; + }; +} diff --git a/packages/html/src/store/container-mixin.ts b/packages/html/src/store/container-mixin.ts new file mode 100644 index 00000000..0592a37e --- /dev/null +++ b/packages/html/src/store/container-mixin.ts @@ -0,0 +1,73 @@ +import { ContextConsumer } from '@lit/context'; +import type { ReactiveElement } from '@lit/reactive-element'; +import type { MediaContainer, PlayerStore, PlayerTarget } from '@videojs/core/dom'; +import { listen, querySlot } from '@videojs/utils/dom'; +import { Disposer } from '@videojs/utils/events'; +import { noop } from '@videojs/utils/function'; +import type { Constructor } from '@videojs/utils/types'; + +import type { PlayerContext } from '../player/context'; +import type { PlayerConsumer } from './types'; + +type Base = Constructor; + +type Result = Class & Constructor>; + +export type ContainerMixin = (BaseClass: Class) => Result; + +export function createContainerMixin(context: PlayerContext): ContainerMixin { + return (BaseClass: Class) => { + class PlayerContainerElement extends BaseClass implements PlayerConsumer, MediaContainer { + #detach = noop; + #disposer = new Disposer(); + + #consumer = new ContextConsumer(this, { + context, + callback: () => this.#attachMedia(), + subscribe: true, + }); + + get store(): Store | null { + return (this.#consumer.value?.store as Store) ?? null; + } + + override connectedCallback() { + super.connectedCallback(); + + if (this.shadowRoot) { + const slot = querySlot(this.shadowRoot, ''); + if (slot) this.#disposer.add(listen(slot, 'slotchange', () => this.#attachMedia())); + } + + this.#attachMedia(); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + this.#disposer.dispose(); + this.#detach(); + } + + #attachMedia() { + const ctx = this.#consumer.value; + if (!ctx) return; + + const { store, media } = ctx; + + if (!media) return; + + const target: PlayerTarget = { + media, + container: this, + }; + + if (store.target?.media !== target.media || store.target?.container !== target.container) { + this.#detach(); + this.#detach = store.attach(target); + } + } + } + + return PlayerContainerElement; + }; +} diff --git a/packages/html/src/store/provider-mixin.ts b/packages/html/src/store/provider-mixin.ts new file mode 100644 index 00000000..9ded5742 --- /dev/null +++ b/packages/html/src/store/provider-mixin.ts @@ -0,0 +1,57 @@ +import { ContextProvider } from '@lit/context'; +import type { ReactiveElement } from '@lit/reactive-element'; +import type { Media, PlayerStore } from '@videojs/core/dom'; +import { isNull } from '@videojs/utils/predicate'; +import type { Constructor } from '@videojs/utils/types'; + +import type { PlayerContext } from '../player/context'; +import type { PlayerProvider } from './types'; + +type Base = Constructor; + +type Result = Class & Constructor>; + +export type ProviderMixin = (BaseClass: Class) => Result; + +export function createProviderMixin( + context: PlayerContext, + factory: () => Store +): ProviderMixin { + return (BaseClass: Class) => { + class PlayerProviderElement extends BaseClass implements PlayerProvider { + #store: Store | null = null; + #media: Media | null = null; + + #provider = new ContextProvider(this, { + context, + initialValue: { store: this.store, media: null }, + }); + + get store(): Store { + if (isNull(this.#store)) { + this.#store = factory(); + } + return this.#store; + } + + get media(): Media | null { + return this.#media; + } + + set media(value: Media | null) { + this.#media = value; + this.#provider.setValue({ store: this.store, media: value }); + } + + override disconnectedCallback() { + super.disconnectedCallback(); + if (this.#store) { + this.#store.destroy(); + this.#store = null; + } + } + } + + return PlayerProviderElement; + }; +} diff --git a/packages/html/src/store/types.ts b/packages/html/src/store/types.ts new file mode 100644 index 00000000..d155f39f --- /dev/null +++ b/packages/html/src/store/types.ts @@ -0,0 +1,10 @@ +import type { Media, PlayerStore } from '@videojs/core/dom'; + +export interface PlayerProvider { + readonly store: Store; + media: Media | null; +} + +export interface PlayerConsumer { + readonly store: Store | null; +} diff --git a/packages/html/src/ui/media-element.ts b/packages/html/src/ui/media-element.ts new file mode 100644 index 00000000..866862d9 --- /dev/null +++ b/packages/html/src/ui/media-element.ts @@ -0,0 +1,3 @@ +import { ReactiveElement } from '@lit/reactive-element'; + +export class MediaElement extends ReactiveElement {} diff --git a/packages/html/tsdown.config.ts b/packages/html/tsdown.config.ts index 819af4b5..2caaac63 100644 --- a/packages/html/tsdown.config.ts +++ b/packages/html/tsdown.config.ts @@ -1,11 +1,17 @@ +import { globSync } from 'node:fs'; import { defineConfig } from 'tsdown'; -// const defineDir = new URL('./src/define', import.meta.url).pathname; -// const defineFiles = readdirSync(defineDir).filter(file => file.endsWith('.ts')); +const defineEntries = Object.fromEntries( + globSync('src/define/**/*.ts').map((file) => { + const key = file.replace('src/', '').replace('.ts', ''); + return [key, file]; + }) +); export default defineConfig({ entry: { index: 'src/index.ts', + ...defineEntries, }, platform: 'browser', format: 'es', diff --git a/packages/html/vitest.config.ts b/packages/html/vitest.config.ts new file mode 100644 index 00000000..162fb279 --- /dev/null +++ b/packages/html/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + passWithNoTests: true, + onConsoleLog: (log) => !log.includes('Lit is in dev mode'), + environment: 'happy-dom', + }, +}); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index b919e0e3..e45650a9 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,29 +1,9 @@ 'use client'; -// Re-exports from core/dom -export { - type AnyPlayerFeature, - type BufferState, - definePlayerFeature, - type FeatureAvailability, - features, - type Media, - type MediaContainer, - type PlaybackState, - type PlayerFeature, - type PlayerStore, - type PlayerTarget, - type SourceState, - selectBuffer, - selectPlayback, - selectSource, - selectTime, - selectVolume, - type TimeState, - type VolumeState, -} from '@videojs/core/dom'; +// Core +export * from '@videojs/core/dom'; -// Re-exports from store (utilities) +// Store export type { Comparator, Selector } from '@videojs/store'; export { createSelector, shallowEqual } from '@videojs/store'; export { useSelector, useStore } from '@videojs/store/react'; diff --git a/packages/store/src/lit/controllers/index.ts b/packages/store/src/lit/controllers/index.ts index a9765f2a..57c4d30a 100644 --- a/packages/store/src/lit/controllers/index.ts +++ b/packages/store/src/lit/controllers/index.ts @@ -1,7 +1,6 @@ export { StoreController, type StoreControllerHost, - type StoreControllerValue, } from './store-controller'; export { SubscriptionController, diff --git a/packages/store/src/lit/controllers/store-controller.ts b/packages/store/src/lit/controllers/store-controller.ts index 9308a9cd..6f217ee4 100644 --- a/packages/store/src/lit/controllers/store-controller.ts +++ b/packages/store/src/lit/controllers/store-controller.ts @@ -1,54 +1,116 @@ +import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-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 type { StoreSource } from '../store-accessor'; -import type { SubscriptionControllerHost } from './subscription-controller'; -import { SubscriptionController } from './subscription-controller'; +import { StoreAccessor, type StoreSource } from '../store-accessor'; -export type StoreControllerHost = SubscriptionControllerHost; +export type StoreControllerHost = ReactiveControllerHost & HTMLElement; -export type StoreControllerValue = InferStoreState; +export type Selector = (state: State) => Result; /** - * Subscribes to store state changes. - * Triggers host updates when state changes. - * Provides access to state and request functions spread together. + * Access store state and actions. * - * Accepts either a direct store instance or a context that provides one. + * Without selector: Returns the store, does NOT subscribe to changes. + * With selector: Returns selected state, triggers update when selected state changes (shallowEqual). * - * @example Direct store + * @example * ```ts - * class MyElement extends LitElement { - * #store = new StoreController(this, store); + * // 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 { volume, setVolume } = this.#store.value; - * return html` - * ${volume} - * - * `; + * const playback = this.#playback.value; + * if (!playback) return nothing; + * return html``; * } * } * ``` - * - * @example Context source - * ```ts - * const { context } = createStore({ features: [playbackFeature] }); - * - * class MyElement extends LitElement { - * #store = new StoreController(this, context); - * } - * ``` */ -export class StoreController { - readonly #sub: SubscriptionController>; +export class StoreController implements ReactiveController { + readonly #host: StoreControllerHost; + readonly #selector: Selector, Result> | undefined; + readonly #accessor: StoreAccessor; - constructor(host: StoreControllerHost, source: StoreSource) { - this.#sub = new SubscriptionController(host, source, { - subscribe: (store, onChange) => store.subscribe(onChange), - getValue: (store) => store as unknown as StoreControllerValue, + #cached: Result | undefined; + #unsubscribe = noop; + + constructor(host: StoreControllerHost, source: StoreSource); + constructor( + host: StoreControllerHost, + source: StoreSource, + selector: Selector, Result> + ); + constructor( + host: StoreControllerHost, + source: StoreSource, + selector?: Selector, 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); + 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); + + this.#unsubscribe = store.subscribe(() => { + const next = selector(store.state as InferStoreState); + if (!shallowEqual(this.#cached, next)) { + this.#cached = next; + this.#host.requestUpdate(); + } }); } +} - get value(): StoreControllerValue { - return this.#sub.value; - } +export namespace StoreController { + export type Host = StoreControllerHost; } diff --git a/packages/store/src/lit/controllers/subscription-controller.ts b/packages/store/src/lit/controllers/subscription-controller.ts index 3ba7f348..5e280d98 100644 --- a/packages/store/src/lit/controllers/subscription-controller.ts +++ b/packages/store/src/lit/controllers/subscription-controller.ts @@ -1,19 +1,14 @@ -import type { Context } from '@lit/context'; -import { ContextConsumer } from '@lit/context'; import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; import { noop } from '@videojs/utils/function'; import { isNull } from '@videojs/utils/predicate'; import type { AnyStore } from '../../core/store'; -import { isStore } from '../../core/store'; -import type { StoreSource } from '../store-accessor'; +import { StoreAccessor, type StoreSource } from '../store-accessor'; export type SubscriptionControllerHost = ReactiveControllerHost & HTMLElement; export interface SubscriptionControllerConfig { - /** Subscribe to the store. Returns an unsubscribe function. */ - subscribe: (store: Store, onChange: () => void) => () => void; - /** Get the current value from the store. */ getValue: (store: Store) => Value; + subscribe: (store: Store, onChange: () => void) => () => void; } /** @@ -25,7 +20,7 @@ export interface SubscriptionControllerConfig { * @example * ```ts * class MyController { - * #ctrl: SubscriptionController; + * #ctrl: SubscriptionController; * * constructor(host: Host, source: StoreSource) { * this.#ctrl = new SubscriptionController(host, source, { @@ -34,16 +29,17 @@ export interface SubscriptionControllerConfig { * }); * } * - * get value() { return this.#ctrl.value; } + * get value() { + * return this.#ctrl.value; + * } * } * ``` */ export class SubscriptionController implements ReactiveController { readonly #host: SubscriptionControllerHost; readonly #config: SubscriptionControllerConfig; - readonly #consumer: ContextConsumer, SubscriptionControllerHost> | null; + readonly #accessor: StoreAccessor; - #directStore: Store | null; #unsubscribe = noop; constructor( @@ -53,24 +49,13 @@ export class SubscriptionController implements Re ) { this.#host = host; this.#config = config; - - 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.#connect(store), - subscribe: false, - }); - } + this.#accessor = new StoreAccessor(host, source, (store) => this.#connect(store)); host.addController(this); } get value(): Value { - const store = this.#store; + const store = this.#accessor.value; if (isNull(store)) { throw new Error('Store not available'); @@ -79,22 +64,6 @@ export class SubscriptionController implements Re return this.#config.getValue(store); } - get #store(): Store | null { - if (this.#consumer) { - return this.#consumer.value ?? null; - } - - return this.#directStore; - } - - hostConnected(): void { - // For direct store, connect immediately - // For context, ContextConsumer triggers callback when value is available - if (this.#directStore) { - this.#connect(this.#directStore); - } - } - hostDisconnected(): void { this.#unsubscribe(); this.#unsubscribe = noop; diff --git a/packages/store/src/lit/controllers/tests/store-controller.test.ts b/packages/store/src/lit/controllers/tests/store-controller.test.ts index 546457c4..7b420038 100644 --- a/packages/store/src/lit/controllers/tests/store-controller.test.ts +++ b/packages/store/src/lit/controllers/tests/store-controller.test.ts @@ -8,30 +8,53 @@ describe('StoreController', () => { document.body.innerHTML = ''; }); - it('returns state and action functions spread together', () => { + 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(value.muted).toBe(false); expect(typeof value.setVolume).toBe('function'); }); - it('updates when state changes', async () => { + it('does not trigger updates without selector', async () => { const { store } = createCoreTestStore(); const host = createTestHost(); - const controller = new StoreController(host, store); + new StoreController(host, store); document.body.appendChild(host); - expect(controller.value.volume).toBe(1); - await store.setVolume(0.5); - expect(controller.value.volume).toBe(0.5); + expect(host.updateCount).toBe(0); + }); + + 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); }); @@ -39,7 +62,7 @@ describe('StoreController', () => { const { store } = createCoreTestStore(); const host = createTestHost(); - new StoreController(host, store); + new StoreController(host, store, (s) => s.volume); document.body.appendChild(host); host.remove(); @@ -53,11 +76,11 @@ describe('StoreController', () => { const { store } = createCoreTestStore(); const host = createTestHost(); - const controller = new StoreController(host, store); + const controller = new StoreController(host, store, (s) => s.volume); document.body.appendChild(host); await store.setVolume(0.5); - expect(controller.value.volume).toBe(0.5); + expect(controller.value).toBe(0.5); host.remove(); @@ -66,6 +89,6 @@ describe('StoreController', () => { // Reconnect document.body.appendChild(host); - expect(controller.value.volume).toBe(0.8); + expect(controller.value).toBe(0.8); }); }); diff --git a/packages/store/src/lit/create-store.ts b/packages/store/src/lit/create-store.ts deleted file mode 100644 index bd32a831..00000000 --- a/packages/store/src/lit/create-store.ts +++ /dev/null @@ -1,222 +0,0 @@ -import type { Context } from '@lit/context'; -import { ContextConsumer, createContext } from '@lit/context'; -import type { ReactiveControllerHost, ReactiveElement } from '@lit/reactive-element'; -import { noop } from '@videojs/utils/function'; -import type { Constructor } from '@videojs/utils/types'; -import type { StoreCallbacks } from '../core/config'; -import type { InferSliceState, InferSliceTarget, Slice } from '../core/slice'; -import type { AnyStore, InferStoreState, Store } from '../core/store'; -import { createStore as createCoreStore } from '../core/store'; -import { createContainerMixin, createProviderMixin, createStoreMixin } from './mixins'; -import type { StoreConsumer, StoreProvider } from './types'; - -export const contextKey = Symbol('@videojs/store'); - -export interface CreateStoreOptions extends StoreCallbacks {} - -export type CreateStoreHost = ReactiveControllerHost & HTMLElement; - -export type StoreControllerValue = InferStoreState; - -export interface CreateStoreResult { - /** - * Combined mixin: provides store via context AND auto-attaches slotted media. - * - * @example - * ```ts - * class MyPlayer extends StoreMixin(LitElement) {} - * ``` - */ - StoreMixin: >(Base: T) => T & Constructor>; - - /** - * Mixin that provides store via context (no auto-attach). - * - * Use when you need granular control over store provisioning. - * - * @example - * ```ts - * class MyProvider extends ProviderMixin(LitElement) {} - * ``` - */ - ProviderMixin: >(Base: T) => T & Constructor>; - - /** - * Mixin that auto-attaches slotted media elements (requires store from context). - * - * Use when inheriting store from a parent provider. - * - * @example - * ```ts - * class MyControls extends ContainerMixin(LitElement) {} - * ``` - */ - ContainerMixin: >(Base: T) => T & Constructor>; - - /** - * 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; - * } - * ``` - */ - context: Context; - - /** - * 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: () => S; - - /** - * Store controller bound to this store's context. - * Subscribes to store state changes and provides access to state and request functions. - * - * @example - * ```ts - * const { StoreController } = createStore(playbackSlice); - * - * class MyElement extends LitElement { - * #store = new StoreController(this); - * - * render() { - * const { volume, setVolume } = this.#store.value; - * return html` - * ${volume} - * - * `; - * } - * } - * ``` - */ - StoreController: new ( - host: CreateStoreHost - ) => { - value: StoreControllerValue; - hostConnected: () => void; - hostDisconnected: () => void; - }; -} - -/** - * Creates a store factory that returns mixins, context, bound controllers, and a create function. - * - * @param slice - Slice defining state and attach behavior - * @param options - Optional store lifecycle hooks - * @returns An object containing mixins, context, bound controllers, and create function - * - * @example - * ```ts - * import { createStore } from '@videojs/store/lit'; - * import { combine, defineSlice } from '@videojs/store'; - * - * const playbackSlice = defineSlice()({ - * state: () => ({ paused: true }), - * attach: ({ target, set, signal }) => { - * target.addEventListener('play', () => set({ paused: false }), { signal }); - * target.addEventListener('pause', () => set({ paused: true }), { signal }); - * }, - * }); - * - * const { StoreMixin, StoreController } = createStore(playbackSlice); - * - * // Create a player element with store - * class MyPlayer extends StoreMixin(LitElement) {} - * - * // Create a control element that uses the store via context - * class MyControl extends LitElement { - * #store = new StoreController(this); - * - * render() { - * const { paused } = this.#store.value; - * return html`${paused ? 'Paused' : 'Playing'}`; - * } - * } - * - * customElements.define('my-player', MyPlayer); - * customElements.define('my-control', MyControl); - * ``` - */ -export function createStore>( - slice: S, - options?: CreateStoreOptions, InferSliceState> -): CreateStoreResult, InferSliceState>> { - type Target = InferSliceTarget; - type ProvidedStore = Store>; - - const context = createContext(contextKey); - - function create(): ProvidedStore { - return createCoreStore()(slice, options); - } - - const ProviderMixin = createProviderMixin(context, create); - const ContainerMixin = createContainerMixin(context); - const StoreMixin = createStoreMixin(context, create); - - class StoreController { - readonly #host: CreateStoreHost; - readonly #consumer: ContextConsumer; - #unsubscribe = noop; - - constructor(host: CreateStoreHost) { - this.#host = host; - - this.#consumer = new ContextConsumer(host, { - context, - subscribe: true, - callback: (store) => this.#connect(store), - }); - - host.addController(this); - } - - get value(): StoreControllerValue { - const store = this.#consumer.value; - - if (!store) { - throw new Error('Store not available'); - } - - // State and actions are directly on the store object - return store as unknown as StoreControllerValue; - } - - hostConnected(): void { - this.#consumer.hostConnected(); - } - - hostDisconnected(): void { - this.#unsubscribe(); - this.#unsubscribe = noop; - } - - #connect(store: ProvidedStore | undefined): void { - this.#unsubscribe(); - if (!store) return; - this.#unsubscribe = store.subscribe(() => this.#host.requestUpdate()); - } - } - - return { - StoreMixin, - ProviderMixin, - ContainerMixin, - context, - create, - StoreController, - }; -} diff --git a/packages/store/src/lit/index.ts b/packages/store/src/lit/index.ts index 6d3d460c..a57bce28 100644 --- a/packages/store/src/lit/index.ts +++ b/packages/store/src/lit/index.ts @@ -1,22 +1,2 @@ -export { StoreController, SubscriptionController } from './controllers'; -export type { - CreateStoreHost, - CreateStoreOptions, - CreateStoreResult, - contextKey, -} from './create-store'; -// createStore factory (returns bound controllers) -export { createStore } from './create-store'; - -// Mixin factories (for advanced use cases) -export { - createContainerMixin, - createProviderMixin, - createStoreMixin, -} from './mixins'; -export type { StoreSource } from './store-accessor'; -// StoreAccessor (for custom controllers) -export { StoreAccessor, type StoreAccessorHost } from './store-accessor'; - -// Types -export type { StoreConsumer, StoreProvider } from './types'; +export * from './controllers'; +export * from './store-accessor'; diff --git a/packages/store/src/lit/mixins/container-mixin.ts b/packages/store/src/lit/mixins/container-mixin.ts deleted file mode 100644 index 8a6c7985..00000000 --- a/packages/store/src/lit/mixins/container-mixin.ts +++ /dev/null @@ -1,79 +0,0 @@ -import type { Context } from '@lit/context'; -import { ContextConsumer } from '@lit/context'; -import type { ReactiveElement } from '@lit/reactive-element'; -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'; -import type { Constructor, Mixin } from '@videojs/utils/types'; -import type { AnyStore, InferStoreTarget } from '../../core/store'; -import type { StoreConsumer } from '../types'; - -/** - * Creates a mixin that consumes a store from context and auto-attaches media elements. - * - * @example - * ```ts - * const { ContainerMixin } = createStore({ features: [playbackFeature] }); - * - * class MyControls extends ContainerMixin(LitElement) {} - * ``` - */ -export function createContainerMixin( - context: Context -): Mixin> { - return >(BaseClass: Base) => { - class StoreAttachElement extends BaseClass implements StoreConsumer { - #disposer = new Disposer(); - #detach = noop; - - #consumer = new ContextConsumer(this, { - context, - callback: () => this.#attachMedia(), - subscribe: false, - }); - - get store(): Store | null { - return this.#consumer.value ?? null; - } - - override connectedCallback() { - super.connectedCallback(); - - const shadow = this.shadowRoot; - if (shadow) { - const slot = querySlot(shadow, ''); - 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; - - const findMedia = (el: Element): HTMLMediaElement | null => - isHTMLMediaElement(el) ? el : el.querySelector('video, audio'); - - const media = this.shadowRoot - ? getSlottedElement(this.shadowRoot, '', findMedia) - : this.querySelector('video, audio'); - - if (store.target !== media) { - this.#detach(); - this.#detach = store.attach(media as InferStoreTarget); - } - } - } - - return StoreAttachElement; - }; -} diff --git a/packages/store/src/lit/mixins/index.ts b/packages/store/src/lit/mixins/index.ts deleted file mode 100644 index e82ac62a..00000000 --- a/packages/store/src/lit/mixins/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { createContainerMixin } from './container-mixin'; -export { createProviderMixin } from './provider-mixin'; -export { createStoreMixin } from './store-mixin'; diff --git a/packages/store/src/lit/mixins/provider-mixin.ts b/packages/store/src/lit/mixins/provider-mixin.ts deleted file mode 100644 index b730568b..00000000 --- a/packages/store/src/lit/mixins/provider-mixin.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { Context } from '@lit/context'; -import { ContextProvider } from '@lit/context'; -import type { ReactiveElement } from '@lit/reactive-element'; -import { isNull } from '@videojs/utils/predicate'; -import type { Constructor } from '@videojs/utils/types'; -import type { AnyStore } from '../../core/store'; -import type { StoreProvider } from '../types'; - -/** - * Creates a mixin that provides a store via context. - * - * @example - * ```ts - * const { ProviderMixin } = createStore({ - * features: [playbackFeature] - * }); - * - * class MyPlayer extends ProviderMixin(LitElement) { - * render() { - * return html``; - * } - * } - * ``` - */ -export function createProviderMixin( - context: Context, - factory: () => Store -): >(BaseClass: Base) => Base & Constructor> { - return >(BaseClass: Base) => { - class StoreProviderElement extends BaseClass implements StoreProvider { - #store: Store | null = null; - #isOwner = false; - - #provider = new ContextProvider(this, { - context, - initialValue: this.store, - }); - - get store(): Store { - if (isNull(this.#store)) { - this.#store = factory(); - this.#isOwner = true; - } - - return this.#store; - } - - set store(newStore: Store) { - 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; - }; -} diff --git a/packages/store/src/lit/mixins/store-mixin.ts b/packages/store/src/lit/mixins/store-mixin.ts deleted file mode 100644 index 8f2dd1c3..00000000 --- a/packages/store/src/lit/mixins/store-mixin.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { Context } from '@lit/context'; -import type { ReactiveElement } from '@lit/reactive-element'; -import type { Constructor, Mixin } from '@videojs/utils/types'; -import type { AnyStore } from '../../core/store'; -import type { StoreProvider } from '../types'; - -import { createContainerMixin } from './container-mixin'; -import { createProviderMixin } 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({ features: [playbackFeature] }); - * - * class MyPlayer extends StoreMixin(LitElement) { - * render() { - * return html``; - * } - * } - * ``` - */ -export function createStoreMixin( - context: Context, - factory: () => Store -): Mixin> { - const ProviderMixin = createProviderMixin(context, factory); - const ContainerMixin = createContainerMixin(context); - - return >(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(ContainerMixin(BaseClass)); - }; -} diff --git a/packages/store/src/lit/mixins/tests/combined-mixin.test.ts b/packages/store/src/lit/mixins/tests/combined-mixin.test.ts deleted file mode 100644 index 472a9673..00000000 --- a/packages/store/src/lit/mixins/tests/combined-mixin.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createLitTestStore, setupDomCleanup, TestBaseElement, uniqueTag } from '../../tests/test-utils'; - -setupDomCleanup(); - -function createShadowWithSlot(el: HTMLElement): ShadowRoot { - const shadow = el.attachShadow({ mode: 'open' }); - shadow.innerHTML = ''; - return shadow; -} - -describe('createStoreMixin', () => { - it('provides store and attaches media', async () => { - const { StoreMixin } = createLitTestStore(); - const tagName = uniqueTag('test-combined'); - - class TestElement extends StoreMixin(TestBaseElement) { - override createRenderRoot() { - return createShadowWithSlot(this); - } - } - 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).toMatchObject({ 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() { - return createShadowWithSlot(this); - } - } - 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 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() { - return createShadowWithSlot(this); - } - } - customElements.define(tagName, TestElement); - - const el = document.createElement(tagName) as TestElement; - const wrapper = document.createElement('div'); - 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() { - return createShadowWithSlot(this); - } - } - customElements.define(tagName, TestElement); - - const el = document.createElement(tagName) as TestElement; - const audio = document.createElement('audio'); - 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() { - return createShadowWithSlot(this); - } - } - customElements.define(tagName, TestElement); - - const el = document.createElement(tagName) as TestElement; - const video1 = document.createElement('video'); - const video2 = document.createElement('video'); - 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); - }); -}); diff --git a/packages/store/src/lit/mixins/tests/container-mixin.test.ts b/packages/store/src/lit/mixins/tests/container-mixin.test.ts deleted file mode 100644 index 979cf008..00000000 --- a/packages/store/src/lit/mixins/tests/container-mixin.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createLitTestStore, setupDomCleanup, TestBaseElement, uniqueTag } from '../../tests/test-utils'; - -setupDomCleanup(); - -describe('createContainerMixin', () => { - it('exposes store property (initially null without context)', async () => { - const { ContainerMixin } = createLitTestStore(); - const tagName = uniqueTag('test-container-standalone'); - - class TestElement extends ContainerMixin(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 { ContainerMixin } = createLitTestStore(); - - class MixedElement extends ContainerMixin(TestBaseElement) {} - - expect(MixedElement.prototype).toBeInstanceOf(TestBaseElement); - }); -}); diff --git a/packages/store/src/lit/mixins/tests/provider-mixin.test.ts b/packages/store/src/lit/mixins/tests/provider-mixin.test.ts deleted file mode 100644 index 93e8ce25..00000000 --- a/packages/store/src/lit/mixins/tests/provider-mixin.test.ts +++ /dev/null @@ -1,114 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { createLitTestStore, setupDomCleanup, TestBaseElement, uniqueTag } from '../../tests/test-utils'; - -setupDomCleanup(); - -describe('createProviderMixin', () => { - it('creates store lazily on first access', async () => { - const { ProviderMixin } = createLitTestStore(); - const tagName = uniqueTag('test-provider'); - - class TestElement extends ProviderMixin(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).toMatchObject({ volume: 1, muted: false }); - }); - - it('reuses same store instance', async () => { - const { ProviderMixin } = createLitTestStore(); - const tagName = uniqueTag('test-provider-reuse'); - - class TestElement extends ProviderMixin(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 { ProviderMixin } = createLitTestStore(); - const tagName = uniqueTag('test-provider-destroy'); - - class TestElement extends ProviderMixin(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 { ProviderMixin, create } = createLitTestStore(); - const tagName = uniqueTag('test-provider-setter'); - - class TestElement extends ProviderMixin(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 { ProviderMixin, create } = createLitTestStore(); - const tagName = uniqueTag('test-provider-external'); - - class TestElement extends ProviderMixin(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 { ProviderMixin, create } = createLitTestStore(); - const tagName = uniqueTag('test-provider-replace'); - - class TestElement extends ProviderMixin(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); - }); -}); diff --git a/packages/store/src/lit/mixins/tests/types.test.ts b/packages/store/src/lit/mixins/tests/types.test.ts deleted file mode 100644 index 683e04aa..00000000 --- a/packages/store/src/lit/mixins/tests/types.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -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; - - // Verify store property exists on the mixed type - expectTypeOf().toHaveProperty('store'); - }); - - it('providerMixin adds store property', () => { - const { ProviderMixin } = createLitTestStore(); - const _MixedElement = ProviderMixin(TestBaseElement); - type Instance = InstanceType; - - // Verify store property exists on the mixed type - expectTypeOf().toHaveProperty('store'); - }); - - it('containerMixin adds store property', () => { - const { ContainerMixin } = createLitTestStore(); - const _MixedElement = ContainerMixin(TestBaseElement); - type Instance = InstanceType; - - // Verify store property exists on the mixed type - expectTypeOf().toHaveProperty('store'); - }); -}); diff --git a/packages/store/src/lit/store-accessor.ts b/packages/store/src/lit/store-accessor.ts index c1f4de2a..b6bd5ef9 100644 --- a/packages/store/src/lit/store-accessor.ts +++ b/packages/store/src/lit/store-accessor.ts @@ -1,11 +1,10 @@ import type { Context } from '@lit/context'; import { ContextConsumer } from '@lit/context'; -import type { ReactiveControllerHost } from '@lit/reactive-element'; +import type { ReactiveController, ReactiveControllerHost } from '@lit/reactive-element'; import { noop } from '@videojs/utils/function'; import type { AnyStore } from '../core/store'; import { isStore } from '../core/store'; -/** A store instance or a context that provides one. */ export type StoreSource = Store | Context; export type StoreAccessorHost = ReactiveControllerHost & HTMLElement; @@ -28,14 +27,14 @@ export type StoreAccessorHost = ReactiveControllerHost & HTMLElement; * accessor.value; // null until context provides store * ``` */ -export class StoreAccessor { +export class StoreAccessor implements ReactiveController { readonly #onAvailable: (store: Store) => void; readonly #consumer: ContextConsumer, StoreAccessorHost> | null; #directStore: Store | null; constructor(host: StoreAccessorHost, source: StoreSource, onAvailable?: (store: Store) => void) { - this.#onAvailable = onAvailable ?? (noop as (store: Store) => void); + this.#onAvailable = onAvailable ?? noop; // Check if source is a store (object with subscribe) or context (symbol/string) if (isStore(source)) { @@ -49,6 +48,8 @@ export class StoreAccessor { subscribe: false, }); } + + host.addController(this); } /** Returns the store, or null if not yet available from context. */ @@ -56,19 +57,15 @@ export class StoreAccessor { if (this.#consumer) { return this.#consumer.value ?? null; } + return this.#directStore; } - /** - * Call from hostConnected to re-trigger onAvailable. - * For context sources, the ContextConsumer handles reconnect automatically. - * For direct stores, this manually triggers onAvailable. - */ 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); } - // Context consumer handles its own reconnect via callback } } diff --git a/packages/store/src/lit/tests/create-store.test.ts b/packages/store/src/lit/tests/create-store.test.ts deleted file mode 100644 index 52bf9726..00000000 --- a/packages/store/src/lit/tests/create-store.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { defineSlice } 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 = defineSlice()({ - state: ({ task }) => ({ - volume: 1, - muted: false, - setVolume(volume: number) { - return task(({ target }) => { - 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); - }); - }, - }); - - describe('create', () => { - it('creates a store instance', () => { - const { create } = createStore(audioSlice); - - const store = create(); - - expect(store).toBeDefined(); - expect(store.state).toMatchObject({ volume: 1, muted: false }); - }); - - it('creates independent store instances', () => { - const { create } = createStore(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(audioSlice); - const result2 = createStore(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(audioSlice); - - expect(context).toBeDefined(); - }); - }); - - describe('mixins', () => { - it('returns StoreMixin', () => { - const { StoreMixin } = createStore(audioSlice); - - expect(typeof StoreMixin).toBe('function'); - }); - - it('returns ProviderMixin', () => { - const { ProviderMixin } = createStore(audioSlice); - - expect(typeof ProviderMixin).toBe('function'); - }); - - it('returns ContainerMixin', () => { - const { ContainerMixin } = createStore(audioSlice); - - expect(typeof ContainerMixin).toBe('function'); - }); - - it('mixins can be applied to TestBaseElement', () => { - const { StoreMixin, ProviderMixin, ContainerMixin } = createStore(audioSlice); - - const Mixed1 = StoreMixin(TestBaseElement); - const Mixed2 = ProviderMixin(TestBaseElement); - const Mixed3 = ContainerMixin(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(audioSlice); - - expect(result).toHaveProperty('StoreMixin'); - expect(result).toHaveProperty('ProviderMixin'); - expect(result).toHaveProperty('ContainerMixin'); - expect(result).toHaveProperty('context'); - expect(result).toHaveProperty('create'); - expect(result).toHaveProperty('StoreController'); - }); - }); - - describe('bound controllers', () => { - it('StoreController is a class', () => { - const { StoreController } = createStore(audioSlice); - - expect(typeof StoreController).toBe('function'); - expect(StoreController.prototype).toBeDefined(); - }); - - // Note: Full integration tests with DOM and context would require - // setting up a provider element hierarchy. The bound controllers - // work via context, which is tested in integration tests. - }); -}); diff --git a/packages/store/src/lit/tests/test-utils.ts b/packages/store/src/lit/tests/test-utils.ts index cc823dde..d5cf03f7 100644 --- a/packages/store/src/lit/tests/test-utils.ts +++ b/packages/store/src/lit/tests/test-utils.ts @@ -4,7 +4,6 @@ import { afterEach } from 'vitest'; import { defineSlice } from '../../core/slice'; import type { Store } from '../../core/store'; 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 {} @@ -148,11 +147,6 @@ export function createCustomKeyTestStore(): { store: CustomKeyStore; target: Moc return { store, target }; } -// For mixin tests - creates lit store factory -export function createLitTestStore() { - return createLitStore(audioSlice); -} - /** Type alias for test host. */ export type TestHost = TestHostElement; diff --git a/packages/store/src/lit/types.ts b/packages/store/src/lit/types.ts deleted file mode 100644 index 77bc9443..00000000 --- a/packages/store/src/lit/types.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AnyStore } from '../core/store'; - -export interface StoreProvider { - store: Store; -} - -export interface StoreConsumer { - readonly store: Store | null; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bfe83430..6310c620 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,10 +179,13 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/html: dependencies: + '@lit/context': + specifier: ^1.1.0 + version: 1.1.6 '@lit/reactive-element': specifier: ^2.1.2 version: 2.1.2 @@ -196,12 +199,21 @@ importers: specifier: workspace:* version: link:../utils devDependencies: + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.1 + happy-dom: + specifier: ^18.0.1 + version: 18.0.1 tsdown: specifier: ^0.15.12 version: 0.15.12(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/icons: devDependencies: @@ -247,7 +259,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/store: dependencies: @@ -284,7 +296,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages/utils: devDependencies: @@ -296,7 +308,7 @@ importers: version: 5.9.3 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) site: dependencies: @@ -432,7 +444,7 @@ importers: version: 7.2.2 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) packages: @@ -2347,6 +2359,9 @@ packages: '@types/node@17.0.45': resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} + '@types/node@20.19.30': + resolution: {integrity: sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==} + '@types/node@22.19.3': resolution: {integrity: sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==} @@ -2388,6 +2403,9 @@ packages: '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -3243,6 +3261,10 @@ packages: h3@1.15.4: resolution: {integrity: sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ==} + happy-dom@18.0.1: + resolution: {integrity: sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==} + engines: {node: '>=20.0.0'} + has-flag@4.0.0: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} @@ -5065,6 +5087,10 @@ packages: engines: {node: '>=18'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} engines: {node: '>=18'} @@ -7059,6 +7085,10 @@ snapshots: '@types/node@17.0.45': {} + '@types/node@20.19.30': + dependencies: + undici-types: 6.21.0 + '@types/node@22.19.3': dependencies: undici-types: 6.21.0 @@ -7106,6 +7136,8 @@ snapshots: '@types/unist@3.0.3': {} + '@types/whatwg-mimetype@3.0.2': {} + '@ungap/structured-clone@1.3.0': {} '@vercel/analytics@1.6.1(next@16.0.10(@babel/core@7.28.5)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)': @@ -7187,7 +7219,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -7236,7 +7268,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) + vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) '@vitest/utils@3.2.4': dependencies: @@ -8086,6 +8118,12 @@ snapshots: ufo: 1.6.1 uncrypto: 0.1.3 + happy-dom@18.0.1: + dependencies: + '@types/node': 20.19.30 + '@types/whatwg-mimetype': 3.0.2 + whatwg-mimetype: 3.0.0 + has-flag@4.0.0: {} hast-util-from-html@2.0.3: @@ -10202,7 +10240,7 @@ snapshots: optionalDependencies: vite: 6.4.1(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2) - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -10231,6 +10269,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 22.19.3 '@vitest/ui': 3.2.4(vitest@3.2.4) + happy-dom: 18.0.1 jsdom: 26.1.0 transitivePeerDependencies: - jiti @@ -10246,7 +10285,7 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): + vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(happy-dom@18.0.1)(jiti@2.6.1)(jsdom@27.3.0(postcss@8.5.6))(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 @@ -10275,6 +10314,7 @@ snapshots: '@types/debug': 4.1.12 '@types/node': 22.19.3 '@vitest/ui': 3.2.4(vitest@3.2.4) + happy-dom: 18.0.1 jsdom: 27.3.0(postcss@8.5.6) transitivePeerDependencies: - jiti @@ -10310,6 +10350,8 @@ snapshots: dependencies: iconv-lite: 0.6.3 + whatwg-mimetype@3.0.0: {} + whatwg-mimetype@4.0.0: {} whatwg-url@14.2.0: