diff --git a/.changeset/slimy-lizards-tickle.md b/.changeset/slimy-lizards-tickle.md index e4835189..e7a03a65 100644 --- a/.changeset/slimy-lizards-tickle.md +++ b/.changeset/slimy-lizards-tickle.md @@ -6,7 +6,7 @@ '@remote-dom/core': minor --- -## Added native support for synchronizing attributes and event listeners +Added native support for synchronizing attributes and event listeners Previously, Remote DOM only offered “remote properties” as a way to synchronize element state between the host and remote environments. These remote properties effectively synchronize a subset of a custom element’s instance properties. The `RemoteElement` class offers [a declarative way to define the properties that should be synchronized](/packages/core/README.md#remote-properties). @@ -110,7 +110,7 @@ myElement.addEventListener('change', () => console.log('Changed!')); // No `myElement.onChange` property is created ``` -The `remoteProperties` configuration will continue to be supported for cases where you want to synchronize instance properties. Because instance properties can be any JavaScript type, properties are the highest-fidelity field that can be synchronized between the remote and host environments. However, adding event listeners using the `remoteProperties.event` configuration is **deprecated and will be removed in the next major version**. You should use the `remoteEvents` configuration instead. If you were previously defining remote properties which only accepted strings, consider using the `remoteAttributes` configuration instead, which stores the value entirely in an HTML attribute instead. +The `remoteProperties` configuration will continue to be supported for cases where you want to synchronize instance properties. Because instance properties can be any JavaScript type, properties are the highest-fidelity field that can be synchronized between the remote and host environments. However, adding event listeners using the `remoteProperties.event` configuration is **deprecated and will be removed in the next major version**. You should use the `remoteEvents` configuration instead. If you were previously defining remote properties which only accepted strings, consider using the `remoteAttributes` configuration, which stores the value entirely in an HTML attribute instead. This change is being released in a backwards-compatible way, so you can continue to use the existing `remoteProperties` configuration on host and/or remote environments without any code changes. diff --git a/packages/core/source/elements.ts b/packages/core/source/elements.ts index 0b667cce..eb771803 100644 --- a/packages/core/source/elements.ts +++ b/packages/core/source/elements.ts @@ -2,14 +2,6 @@ export { RemoteElement, createRemoteElement, type RemoteElementConstructor, - type RemoteElementPropertyType, - type RemoteElementPropertyDefinition, - type RemoteElementPropertiesDefinition, - type RemoteElementAttributeDefinition, - type RemoteElementEventListenerDefinition, - type RemoteElementEventListenersDefinition, - type RemoteElementSlotDefinition, - type RemoteElementSlotsDefinition, type RemotePropertiesFromElementConstructor, type RemoteMethodsFromElementConstructor, type RemoteSlotsFromElementConstructor, @@ -19,6 +11,16 @@ export { export {RemoteFragmentElement} from './elements/RemoteFragmentElement.ts'; export {RemoteRootElement} from './elements/RemoteRootElement.ts'; export {RemoteReceiverElement} from './elements/RemoteReceiverElement.ts'; +export type { + RemoteElementPropertyType, + RemoteElementPropertyDefinition, + RemoteElementPropertiesDefinition, + RemoteElementAttributeDefinition, + RemoteElementEventListenerDefinition, + RemoteElementEventListenersDefinition, + RemoteElementSlotDefinition, + RemoteElementSlotsDefinition, +} from './elements/types.ts'; export {RemoteEvent} from './elements/RemoteEvent.ts'; export {RemoteMutationObserver} from './elements/RemoteMutationObserver.ts'; diff --git a/packages/core/source/elements/RemoteElement.ts b/packages/core/source/elements/RemoteElement.ts index e7ad4eff..816ec90a 100644 --- a/packages/core/source/elements/RemoteElement.ts +++ b/packages/core/source/elements/RemoteElement.ts @@ -7,106 +7,23 @@ import { remoteProperties as getRemoteProperties, remoteEventListeners as getRemoteEventListeners, } from './internals.ts'; - -export interface RemoteElementPropertyType { - parse?(value: string | unknown): Value; - serialize?(value: Value): string | unknown; -} - -export type RemoteElementPropertyTypeOrBuiltIn = - | typeof String - | typeof Number - | typeof Boolean - | typeof Object - | typeof Array - | typeof Function - | RemoteElementPropertyType; - -export interface RemoteElementPropertyDefinition { - type?: RemoteElementPropertyTypeOrBuiltIn; - alias?: string[]; - /** - * @deprecated Use `RemoteElement.eventListeners` instead. - */ - event?: boolean | string; - attribute?: string | boolean; - default?: Value; -} - -interface RemoteElementPropertyNormalizedDefinition { - name: string; - type: RemoteElementPropertyTypeOrBuiltIn; - alias?: string[]; - event?: string; - attribute?: string; - default?: Value; -} - -export type RemoteElementPropertiesDefinition< - Properties extends Record = {}, -> = { - [Property in keyof Properties]: RemoteElementPropertyDefinition< - Properties[Property] - >; -}; - -export interface RemoteElementSlotDefinition {} - -export interface RemoteElementAttributeDefinition {} - -export interface RemoteElementEventListenerDefinition { - bubbles?: boolean; - property?: boolean | string; - dispatchEvent?: ( - this: RemoteElement, - arg: any, - ) => Event | undefined | void; -} - -export type RemoteElementEventListenersDefinition< - EventListeners extends Record = {}, -> = { - [Event in keyof EventListeners]: RemoteElementEventListenerDefinition; -}; - -export interface RemoteElementMethodDefinition {} - -export type RemoteElementSlotsDefinition< - Slots extends Record = {}, -> = { - [Slot in keyof Slots]: RemoteElementSlotDefinition; -}; - -export type RemoteElementMethodsDefinition< - Slots extends Record = {}, -> = { - [Slot in keyof Slots]: RemoteElementMethodDefinition; -}; - -export type RemotePropertiesFromElementConstructor = T extends { - new (): RemoteElement; -} - ? Properties - : never; - -export type RemoteMethodsFromElementConstructor = T extends { - new (): RemoteElement; -} - ? Methods - : never; - -export type RemoteSlotsFromElementConstructor = T extends { - new (): RemoteElement; -} - ? Slots - : never; - -export type RemoteEventListenersFromElementConstructor = T extends { - new (): RemoteElement; -} - ? EventListeners - : never; - +import type { + RemoteElementAttributeDefinition, + RemoteElementEventListenerDefinition, + RemoteElementEventListenersDefinition, + RemoteElementPropertiesDefinition, + RemoteElementPropertyDefinition, + RemoteElementPropertyType, + RemoteElementPropertyTypeOrBuiltIn, + RemoteElementSlotsDefinition, + RemoteElementSlotDefinition, +} from './types.ts'; + +/** + * A class that represents a remote custom element, which can have properties, + * attributes, event listeners, methods, and slots that are synchronized with + * a host environment. + */ export type RemoteElementConstructor< Properties extends Record = {}, Methods extends Record any> = {}, @@ -116,70 +33,174 @@ export type RemoteElementConstructor< new (): RemoteElement & Properties & Methods; + + /** + * The slots that can be populated on this remote element. + */ readonly remoteSlots?: | RemoteElementSlotsDefinition | readonly (keyof Slots)[]; + + /** + * The resolved slot definitions for this remote element. + */ readonly remoteSlotDefinitions: Map; + /** + * The properties that can be synchronized between this remote element and + * its host representation. + */ readonly remoteProperties?: | RemoteElementPropertiesDefinition | readonly (keyof Properties)[]; + + /** + * The resolved property definitions for this remote element. + */ readonly remotePropertyDefinitions: Map< string, RemoteElementPropertyNormalizedDefinition >; + /** + * Creates a new definition for a property that will be synchronized between + * this remote element and its host representation. + */ + createProperty( + name: string, + definition?: RemoteElementPropertyDefinition, + ): void; + + /** + * The attributes that can be synchronized between this remote element and + * its host representation. + */ readonly remoteAttributes?: readonly string[]; + + /** + * The resolved attribute definitions for this remote element. + */ readonly remoteAttributeDefinitions: Map< string, RemoteElementAttributeDefinition >; + /** + * The event listeners that can be synchronized between this remote element + * and its host representation. + */ readonly remoteEvents?: | RemoteElementEventListenersDefinition | readonly (keyof EventListeners)[]; + + /** + * The resolved event listener definitions for this remote element. + */ readonly remoteEventDefinitions: Map< string, RemoteElementEventListenerDefinition >; + /** + * The methods on the corresponding host element that you can call from the remote + * environment. + */ readonly remoteMethods?: Methods | readonly (keyof Methods)[]; - createProperty( - name: string, - definition?: RemoteElementPropertyDefinition, - ): void; }; +/** + * Returns the properties type from a remote element constructor. + */ +export type RemotePropertiesFromElementConstructor = T extends { + new (): RemoteElement; +} + ? Properties + : never; + +/** + * Returns the methods type from a remote element constructor. + */ +export type RemoteMethodsFromElementConstructor = T extends { + new (): RemoteElement; +} + ? Methods + : never; + +/** + * Returns the slots type from a remote element constructor. + */ +export type RemoteSlotsFromElementConstructor = T extends { + new (): RemoteElement; +} + ? Slots + : never; + +/** + * Returns the event listeners type from a remote element constructor. + */ +export type RemoteEventListenersFromElementConstructor = T extends { + new (): RemoteElement; +} + ? EventListeners + : never; + +/** + * Options that can be passed when creating a new remote element class with + * `createRemoteElement()`. + */ export interface RemoteElementCreatorOptions< Properties extends Record = {}, Methods extends Record = {}, Slots extends Record = {}, EventListeners extends Record = {}, > { + /** + * The slots that can be populated on this remote element. + */ slots?: RemoteElementConstructor< Properties, Methods, Slots, EventListeners >['remoteSlots']; + + /** + * The properties that can be synchronized between this remote element and + * its host representation. + */ properties?: RemoteElementConstructor< Properties, Methods, Slots, EventListeners >['remoteProperties']; + + /** + * The attributes that can be synchronized between this remote element and + * its host representation. + */ attributes?: RemoteElementConstructor< Properties, Methods, Slots, EventListeners >['remoteAttributes']; + + /** + * The event listeners that can be synchronized between this remote element + * and its host representation. + */ events?: RemoteElementConstructor< Properties, Methods, Slots, EventListeners >['remoteEvents']; + + /** + * The methods on the corresponding host element that you can call from the remote + * environment. + */ methods?: RemoteElementConstructor< Properties, Methods, @@ -220,23 +241,28 @@ export function createRemoteElement< return RemoteElementConstructor; } -const REMOTE_EVENTS = Symbol('remote.events'); - -interface RemoteEventRecord { - readonly name: string; - readonly property?: string; - readonly definition?: RemoteElementEventListenerDefinition; - readonly listeners: Set; - dispatch(...args: any[]): unknown; -} - -type RemoteEventListenerRecord = [ - EventListenerOrEventListenerObject, - RemoteEventRecord, -]; - // Heavily inspired by https://github.com/lit/lit/blob/343187b1acbbdb02ce8d01fa0a0d326870419763/packages/reactive-element/src/reactive-element.ts -// @ts-ignore-error + +/** + * A base class for creating “remote” HTML elements, which have properties, attributes, + * event listeners, slots, and methods that can be synchronized between a host and + * remote environment. When subclassing `RemoteElement`, you can define how different fields + * in the class will be synchronized by defining the `remoteProperties`, `remoteAttributes`, + * `remoteEvents`, and/or `remoteMethods` static properties. + * + * @example + * ```ts + * class CustomButton extends RemoteElement { + * static remoteAttributes = ['disabled', 'primary']; + * static remoteEvents = ['click']; + * + * focus() { + * console.log('Calling focus in the remote environment...'); + * return this.callRemoteMethod('focus'); + * } + * } + * ``` + */ export abstract class RemoteElement< Properties extends Record = {}, Methods extends Record any> = {}, @@ -255,6 +281,9 @@ export abstract class RemoteElement< return this.finalize().__observedAttributes; } + /** + * The resolved property definitions for this remote element. + */ static get remotePropertyDefinitions(): Map< string, RemoteElementPropertyNormalizedDefinition @@ -262,6 +291,9 @@ export abstract class RemoteElement< return this.finalize().__remotePropertyDefinitions; } + /** + * The resolved attribute definitions for this remote element. + */ static get remoteAttributeDefinitions(): Map< string, RemoteElementAttributeDefinition @@ -269,6 +301,9 @@ export abstract class RemoteElement< return this.finalize().__remoteAttributeDefinitions; } + /** + * The resolved event listener definitions for this remote element. + */ static get remoteEventDefinitions(): Map< string, RemoteElementEventListenerDefinition @@ -276,6 +311,9 @@ export abstract class RemoteElement< return this.finalize().__remoteEventDefinitions; } + /** + * The resolved slot definitions for this remote element. + */ static get remoteSlotDefinitions(): Map { return this.finalize().__remoteSlotDefinitions; } @@ -301,6 +339,10 @@ export abstract class RemoteElement< RemoteElementSlotDefinition >(); + /** + * Creates a new definition for a property that will be synchronized between + * this remote element and its host representation. + */ static createProperty( name: string, definition?: RemoteElementPropertyDefinition, @@ -315,6 +357,11 @@ export abstract class RemoteElement< ); } + /** + * Consumes all the static members defined on the class and converts them + * into the internal representation used to handle properties, attributes, + * and event listeners. + */ protected static finalize(): typeof this { // eslint-disable-next-line no-prototype-builtins if (this.hasOwnProperty('__finalized')) { @@ -505,15 +552,6 @@ export abstract class RemoteElement< /** @internal */ __eventListeners?: EventListeners; - private [REMOTE_EVENTS]?: { - readonly events: Map; - readonly properties: Map void) | null>; - readonly listeners: WeakMap< - EventListenerOrEventListenerObject, - RemoteEventListenerRecord - >; - }; - constructor() { super(); (this.constructor as typeof RemoteElement).finalize(); @@ -734,8 +772,7 @@ export abstract class RemoteElement< listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions, ) { - const remoteEvents = this[REMOTE_EVENTS]; - const listenerRecord = remoteEvents?.listeners.get(listener); + const listenerRecord = REMOTE_EVENTS.get(this)?.listeners.get(listener); const normalizedListener = listenerRecord ? listenerRecord[0] : listener; super.removeEventListener(type, normalizedListener, options); @@ -745,19 +782,71 @@ export abstract class RemoteElement< removeRemoteListener.call(this, type, listener, listenerRecord); } + /** + * Updates a single remote property on an element node. If the element is + * connected to a remote root, this function will also make a `mutate()` call + * to communicate the change to the host. + */ updateRemoteProperty(name: string, value?: unknown) { updateRemoteElementProperty(this, name, value); } + /** + * Updates a single remote attribute on an element node. If the element is + * connected to a remote root, this function will also make a `mutate()` call + * to communicate the change to the host. + */ updateRemoteAttribute(name: string, value?: string) { updateRemoteElementAttribute(this, name, value); } + /** + * Performs a method through `RemoteConnection.call()`, using the remote ID and + * connection for the provided node. + */ callRemoteMethod(method: string, ...args: any[]) { return callRemoteElementMethod(this, method, ...args); } } +// Utilities + +interface RemoteElementPropertyNormalizedDefinition { + name: string; + type: RemoteElementPropertyTypeOrBuiltIn; + alias?: string[]; + event?: string; + attribute?: string; + default?: Value; +} + +const REMOTE_EVENTS = new WeakMap< + RemoteElement, + RemoteElementEventCache +>(); + +interface RemoteElementEventCache { + readonly events: Map; + readonly properties: Map void) | null>; + readonly listeners: WeakMap< + EventListenerOrEventListenerObject, + RemoteEventListenerRecord + >; +} + +interface RemoteEventRecord { + readonly name: string; + readonly property?: string; + readonly definition?: RemoteElementEventListenerDefinition; + readonly listeners: Set; + dispatch(...args: any[]): unknown; +} + +type RemoteEventListenerRecord = [ + EventListenerOrEventListenerObject, + RemoteEventRecord, +]; + function getRemoteEvents(element: RemoteElement): { events: Map; properties: Map void) | null>; @@ -766,20 +855,19 @@ function getRemoteEvents(element: RemoteElement): { RemoteEventListenerRecord >; } { - if (element[REMOTE_EVENTS]) return element[REMOTE_EVENTS]!; + let events = REMOTE_EVENTS.get(element); + + if (events) return events; - const remoteEvents = { + events = { events: new Map(), properties: new Map(), listeners: new WeakMap(), }; - Object.defineProperty(element, REMOTE_EVENTS, { - value: remoteEvents, - enumerable: false, - }); + REMOTE_EVENTS.set(element, events); - return remoteEvents; + return events; } function getRemoteEventRecord( diff --git a/packages/core/source/elements/RemoteReceiverElement.ts b/packages/core/source/elements/RemoteReceiverElement.ts index 9f2d9685..ac68776f 100644 --- a/packages/core/source/elements/RemoteReceiverElement.ts +++ b/packages/core/source/elements/RemoteReceiverElement.ts @@ -5,7 +5,20 @@ type DOMRemoteReceiverOptions = NonNullable< >; /** - * A custom element that can be used to simplify receiving + * A custom element that can be used to simplify receiving updates to a + * remote tree of elements in a host environment. On the host, you can create + * a `RemoteReceiverElement` and use its `connection` property to connect + * it to a remote environment + * + * @example + * ```ts + * import {RemoteReceiverElement} from '@remote-dom/core/elements'; + * + * customElements.define('remote-receiver', RemoteReceiverElement); + * + * const element = document.createElement('remote-receiver'); + * console.log(element.connection); // RemoteConnection + * ``` */ export class RemoteReceiverElement extends HTMLElement { /** diff --git a/packages/core/source/elements/RemoteRootElement.ts b/packages/core/source/elements/RemoteRootElement.ts index 3f3b0d76..393f83ca 100644 --- a/packages/core/source/elements/RemoteRootElement.ts +++ b/packages/core/source/elements/RemoteRootElement.ts @@ -10,6 +10,28 @@ import { REMOTE_IDS, } from './internals.ts'; +/** + * A custom element that represents the root of a remote tree of elements. + * To use this element, define it as a custom element and create it with + * `document.createElement()`. Then, call its `connect()` method with a + * `RemoteConnection` instance from a host environment, and start appending + * child nodes to the tree. Any changes to the tree nested under this element + * will be synchronized with the host environment automatically. + * + * @example + * ```ts + * import {RemoteRootElement} from '@remote-dom/core/elements'; + * + * customElements.define('remote-root', RemoteRootElement); + * + * const element = document.createElement('remote-root'); + * + * withRemoteConnectionFromHost((connection) => { + * element.connect(connection); + * }); + * + * element.append('Hello world!'); + */ export class RemoteRootElement extends HTMLElement { constructor() { super(); diff --git a/packages/core/source/elements/types.ts b/packages/core/source/elements/types.ts new file mode 100644 index 00000000..dfdadeb0 --- /dev/null +++ b/packages/core/source/elements/types.ts @@ -0,0 +1,133 @@ +/** + * The details for a single attribute that will be defined on a `RemoteElement`. + */ +export interface RemoteElementAttributeDefinition {} + +/** + * The details for a single event listener that will be defined on a `RemoteElement`. + */ +export interface RemoteElementEventListenerDefinition { + bubbles?: boolean; + property?: boolean | string; + dispatchEvent?(this: Element, arg: any): Event | undefined | void; +} + +/** + * Configuration for event listeners that will be synchronized between a remote + * element and its host representation. + */ +export type RemoteElementEventListenersDefinition< + EventListeners extends Record = {}, +> = { + [Event in keyof EventListeners]: RemoteElementEventListenerDefinition; +}; + +/** + * The details for a single method that will be defined on a `RemoteElement`. + */ +export interface RemoteElementMethodDefinition {} + +/** + * Configuration for methods that will be synchronized between a remote element + * and its host representation. + */ +export type RemoteElementMethodsDefinition< + Slots extends Record = {}, +> = { + [Slot in keyof Slots]: RemoteElementMethodDefinition; +}; + +/** + * The details for a single property that will be defined on a `RemoteElement`. + */ +export interface RemoteElementPropertyDefinition { + /** + * The type of the property, which will control how it is reflected to and + * from an attribute. Defaults to assuming the property contains a string value. + */ + type?: RemoteElementPropertyTypeOrBuiltIn; + + /** + * A list of aliases for this property. When the property is set, the aliases + * will be defined as dedicated properties, but will always read and write to the + * same underlying value. + * + * @deprecated + */ + alias?: string[]; + + /** + * Whether the property should be settable using `addEventListener()`. When set to + * `true`, Remote DOM infers the name of the event from the property name. When set + * to a string, Remote DOM uses the string as the event name. When set to anything + * else, there will be no connection between the property and event listener. + * + * @deprecated Use `RemoteElement.eventListeners` instead. + */ + event?: boolean | string; + + /** + * The attribute to reflect this property to. The value of the property will be serialized + * according to the logic you provide with the `type` field. If set to `true` or omitted, an + * attribute will be maintained with same name as the property. You can also set this option to + * a string to provide a custom attribute name. If you want to disable attribute reflection + * altogether, set this option to `false`. + */ + attribute?: string | boolean; + + /** + * The default value for the property. This value will be communicated to the host as if the + * property was set directly. + */ + default?: Value; +} + +/** + * Configuration for properties that will be synchronized between a remote + * element and its host representation. + */ +export type RemoteElementPropertiesDefinition< + Properties extends Record = {}, +> = { + [Property in keyof Properties]: RemoteElementPropertyDefinition< + Properties[Property] + >; +}; + +/** + * An object that can be used to define the type of a property on a remote + * element, which will control its attribute reflection behavior. + */ +export type RemoteElementPropertyTypeOrBuiltIn = + | typeof String + | typeof Number + | typeof Boolean + | typeof Object + | typeof Array + | typeof Function + | RemoteElementPropertyType; + +/** + * An object that provides custom logic for parsing a property from its + * matching attribute value, and that can serialize the property back to + * a string for the attribute. + */ +export interface RemoteElementPropertyType { + parse?(value: string | unknown): Value; + serialize?(value: Value): string | unknown; +} + +/** + * The details for a single slot that can be filled on a `RemoteElement`. + */ +export interface RemoteElementSlotDefinition {} + +/** + * Configuration for slots that will be synchronized between a remote element + * and its host representation. + */ +export type RemoteElementSlotsDefinition< + Slots extends Record = {}, +> = { + [Slot in keyof Slots]: RemoteElementSlotDefinition; +};