import type { Store, ActionCreator, Action, SelfDispatchingActionCreator, } from './store.js'; import { ConnectableInterface, StoreRequestedDetails, StoreConnectedDetails, } from './types.js'; import type { Constructor } from '../types.js'; import type { Selector } from 'reselect'; export const $propertyMap = Symbol('propertyMap'); export interface ConnectedInterface extends ConnectableInterface, EventTarget { [$propertyMap]: Map>; } /** * A class mixin that produces a "connected" extension to a given HTMLElement. * * When a "connected" element attaches to a document, it queries for a store * provider by dispatching a bubbling event. If a provider is listening, it * responds with the instance of the store to be used. * * Any pending self-dispatching actions will dispatch as soon as the store is * associated with the "connected" element. */ export const connect = () => < U extends Constructor< EventTarget & { connectedCallback?(): void; disconnectedCallback?(): void; } > >( SuperClass: U ): U & Constructor> => { class Connected extends SuperClass { get store() { return this.#store; } #store: Store | null = null; #updateState = () => { if (this.#store == null) { return; } const state = this.#store.state; // Much hand-waving ensues: for (const [ property, selector, ] of ((this as unknown) as ConnectedInterface)[ $propertyMap ].entries()) { ((this as unknown) as { [index: string]: unknown })[ property ] = selector(state); } }; protected async dispatch(action: Action): Promise { return this.#store?.dispatch(action); } connectStore(store: Store) { if (this.#store != null) { if (this.#store !== store) { this.disconnectStore(); } else { return; } } this.#store = store; this.#store.addEventListener('state-change', this.#updateState); this.#updateState(); this.dispatchEvent( new CustomEvent>('store-connected', { detail: { store }, bubbles: false, composed: false, }) ); } disconnectStore() { if (this.#store == null) { return; } this.#store.removeEventListener('state-change', this.#updateState); this.#store = null; } connectedCallback() { super.connectedCallback && super.connectedCallback(); this.dispatchEvent( new CustomEvent>('store-requested', { detail: { requestor: this }, bubbles: true, composed: true, }) ); } disconnectedCallback() { super.disconnectedCallback && super.disconnectedCallback(); this.disconnectStore(); } } Connected.prototype[$propertyMap] = new Map>(); return (Connected as unknown) as U & Constructor>; }; const storeConnects = async ( target: ConnectedInterface ): Promise> => { if (target.store == null) { await new Promise((resolve) => { target.addEventListener('store-connected', resolve, { once: true }); }); } return (target.store as unknown) as Store; }; /** * Create a wrapped version of an arbitrary ActionCreator that will * self-dispatch to the store associated with the context that the ActionCreator * is called with. Note that such a wrapped ActionCreator must be called with * a context that expresses the behavior of a ConnectedInterface. * * Example usage: * * import {someActionCreator} from './actions.js'; * * class ConnectedFoo extends connect()(EventTarget) { * #someActionCreator = selfDispatch(someActionCreator); * * onEvent() { * // This will automatically dispatch to the store * // when this instance is connected to it: * this.#someActionCreator(appropriate, args); * } * } */ export const selfDispatch = < T extends {}, U extends ConnectedInterface, V extends ActionCreator, W extends any[] = V extends ActionCreator ? X : any[] >( actionCreator: ActionCreator ): SelfDispatchingActionCreator => async function (this: U, ...args: W) { const store = await storeConnects(this); await store.dispatch(actionCreator(...args)); };