From 00ae9dc82c24cf830982f11a9955c36641fafb53 Mon Sep 17 00:00:00 2001 From: Chris Sauve Date: Wed, 14 Aug 2024 14:57:50 -0400 Subject: [PATCH] Support for remote attributes and event listeners MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `getAttributeNames()` polyfill to support vitest assertions Add event listener support Add proper support for event listeners Clean up some naming Update READMEs Fix Preact tests More documentation More documentation polish Add missing generic argument More docs polish Fix `Event.bubbles` and `Event.composedPath()` implementations Fix missing `connectedCallback()` and `disconnectedCallback)` calls Added `bubbles` configuration option for `RemoteElement` events Fixes to event dispatching (#403) * Make immediatePropogationStopped private-ish * Use workspace polyfill * Stopping immediate propagation also stops regular propagation * Assorted dispatching fixes - Respect stopPropagation throughout both capturing and bubbling - Call listeners on the target element in both the capturing and bubbling phases - Simplify returning defaultPrevented * Add changeset * Better param name * Fix lockfile * Trying again… Revert pnpm fixes This reverts part of commit 0ce1450d70d27254be6a6b3c6cc2fb189bb0fec2. Minimal change to pnpm lockfile --- .changeset/slimy-lizards-tickle.md | 157 ++++++ .changeset/strong-sheep-sing.md | 9 + .changeset/tiny-horses-cheat.md | 5 + README.md | 27 +- examples/custom-element/app/index.html | 8 - examples/custom-element/app/remote.html | 17 +- examples/kitchen-sink/app/remote/elements.ts | 25 +- .../app/remote/examples/preact.tsx | 13 +- .../app/remote/examples/react.tsx | 13 +- packages/core/README.md | 173 ++++++- packages/core/package.json | 2 +- packages/core/source/connection.ts | 1 + packages/core/source/constants.ts | 6 + packages/core/source/elements.ts | 4 + .../core/source/elements/RemoteElement.ts | 351 +++++++++++--- packages/core/source/elements/internals.ts | 142 +++++- packages/core/source/html.ts | 5 +- packages/core/source/index.ts | 5 + packages/core/source/polyfill.ts | 30 +- .../source/receivers/DOMRemoteReceiver.ts | 101 +++- .../core/source/receivers/RemoteReceiver.ts | 50 +- packages/core/source/tests/elements.test.ts | 456 ++++++++++++++++-- packages/core/source/types.ts | 29 +- packages/polyfill/source/Element.ts | 4 + packages/polyfill/source/Event.ts | 50 +- packages/polyfill/source/EventTarget.ts | 29 +- packages/polyfill/source/ParentNode.ts | 2 + packages/polyfill/source/constants.ts | 2 + packages/preact/README.md | 118 ++++- packages/preact/source/component.tsx | 89 ++-- .../source/host/hooks/props-for-element.tsx | 18 +- packages/preact/source/tests/e2e.test.tsx | 42 +- packages/preact/source/types.ts | 8 +- packages/react/README.md | 111 ++++- packages/react/source/component.tsx | 108 +++-- .../source/host/hooks/props-for-element.tsx | 15 +- packages/react/source/types.ts | 8 +- .../signals/source/SignalRemoteReceiver.ts | 66 ++- pnpm-lock.yaml | 2 +- 39 files changed, 1924 insertions(+), 377 deletions(-) create mode 100644 .changeset/slimy-lizards-tickle.md create mode 100644 .changeset/strong-sheep-sing.md create mode 100644 .changeset/tiny-horses-cheat.md diff --git a/.changeset/slimy-lizards-tickle.md b/.changeset/slimy-lizards-tickle.md new file mode 100644 index 00000000..fc1a9126 --- /dev/null +++ b/.changeset/slimy-lizards-tickle.md @@ -0,0 +1,157 @@ +--- +'@remote-dom/polyfill': minor +'@remote-dom/signals': minor +'@remote-dom/preact': minor +'@remote-dom/react': minor +'@remote-dom/core': minor +--- + +## 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 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). + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class MyElement extends RemoteElement { + static get remoteProperties() { + return ['label']; + } +} + +customElements.define('my-element', MyElement); + +const myElement = document.createElement('my-element'); +myElement.label = 'Hello, World!'; +``` + +The same `remoteProperties` configuration can create special handling for attributes and event listeners. By default, a remote property is automatically updated when setting an [attribute](https://developer.mozilla.org/en-US/docs/Glossary/Attribute) of the same name: + +```ts +const myElement = document.createElement('my-element'); +myElement.setAttribute('label', 'Hello, World!'); + +// myElement.label === 'Hello, World!', and this value is synchronized +// with the host environment as a “remote property” +``` + +Similarly, a remote property can be automatically updated when adding an event listener based on a conventional `on` property naming prefix: + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class MyElement extends RemoteElement { + static get remoteProperties() { + return { + onChange: { + event: true, + }, + }; + } +} + +customElements.define('my-element', MyElement); + +const myElement = document.createElement('my-element'); + +// This adds a callback property that is synchronized with the host environment +myElement.onChange = () => console.log('Changed!'); + +// And so does this, but using the `addEventListener` method instead +myElement.addEventListener('change', () => console.log('Changed!')); +``` + +These utilities are handy, but they don’t align with patterns in native DOM elements, particularly when it comes to events. Now, both of these can be represented in a fashion that is more conventional in HTML. The `remoteAttributes` configuration allows you to define a set of element attributes that will be synchronized directly the host environment, instead of being treated as instance properties: + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class MyElement extends RemoteElement { + static get remoteAttributes() { + return ['label']; + } + + // If you want to add instance properties, you can do it with getters and + // setters that manipulate the attribute value: + // + // get label() { + // return this.getAttribute('label'); + // } + // + // set label(value) { + // this.setAttribute('label', value); + // } +} + +customElements.define('my-element', MyElement); + +const myElement = document.createElement('my-element'); +myElement.setAttribute('label', 'Hello, World!'); +``` + +Similarly, the `remoteEvents` configuration allows you to define a set of event listeners that will be synchronized directly with the host environment: + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class MyElement extends RemoteElement { + static get remoteEvents() { + return ['change']; + } +} + +customElements.define('my-element', MyElement); + +const myElement = document.createElement('my-element'); + +// And so does this, but using the `addEventListener` method instead +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. + +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. + +All host utilities have been updated to support the new `attributes` and `eventListeners` property that are synchronized with the remote environment. This includes updates to the [React](/packages/react/README.md#event-listener-props) and [Preact hosts to map events to conventional callback props](/packages/preact/README.md#event-listener-props), and updates to the [`DOMRemoteReceiver` class](/packages/core/README.md#domremotereceiver), which now applies fields to the host element exactly as they were applied in the remote environment: + +```ts +// Remote environment: + +class MyElement extends RemoteElement { + static get remoteEvents() { + return ['change']; + } +} + +customElements.define('my-element', MyElement); + +const myElement = document.createElement('my-element'); + +myElement.addEventListener('change', (event) => { + console.log('Changed! New value: ', event.detail); +}); + +// Host environment: + +class MyElement extends HTMLElement { + connectedCallback() { + // Emit a change event on this element, with detail that will be passed + // to the remote environment + this.addEventListener('change', (event) => { + event.stopImmediatePropagation(); + + this.dispatchEvent( + new CustomEvent('change', { + detail: this.value, + }), + ); + }); + } + + // Additional implementation details of the host custom element... +} + +customElements.define('my-element', MyElement); +``` diff --git a/.changeset/strong-sheep-sing.md b/.changeset/strong-sheep-sing.md new file mode 100644 index 00000000..729fd015 --- /dev/null +++ b/.changeset/strong-sheep-sing.md @@ -0,0 +1,9 @@ +--- +'@remote-dom/polyfill': patch +--- + +Bug fixes to event dispatching + +- Listeners on the target are now called during both the capture and bubble phases. +- `stopPropagation` now respected. +- `stopImmediatePropagation` now also stops regular propagation. diff --git a/.changeset/tiny-horses-cheat.md b/.changeset/tiny-horses-cheat.md new file mode 100644 index 00000000..19a476f1 --- /dev/null +++ b/.changeset/tiny-horses-cheat.md @@ -0,0 +1,5 @@ +--- +'@remote-dom/polyfill': patch +--- + +Fix `Event.bubbles` and `Event.composedPath()` implementations diff --git a/README.md b/README.md index be420556..b7e317f1 100644 --- a/README.md +++ b/README.md @@ -147,9 +147,9 @@ Now, just mirroring HTML strings isn’t very useful. Remote DOM works best when Remote DOM adopts the browser’s [native API for defining custom elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements) to represent these “remote custom elements”. To make it easy to define custom elements that can communicate their changes to the host, `@remote-dom/core` provides the [`RemoteElement` class](/packages/core/README.md#remoteelement). This class, which is a subclass of the browser’s [`HTMLElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement), lets you define how properties, attributes, methods, and event listeners on the element should be transferred. -To demonstrate, let’s imagine that we want to allow our remote environment to render a `ui-button` element. This element will have a `primary` property, which sets it to a more prominent visual style. It will also trigger a `click` event when clicked. +To demonstrate, let’s imagine that we want to allow our remote environment to render a `ui-button` element. This element will have a `primary` attribute, which sets it to a more prominent visual style. It will also trigger a `click` event when clicked. -First, we’ll create the remote environment’s version of `ui-button`. The remote version doesn’t have to worry about rendering any HTML — it’s only a signal to the host environment to render the “real” version. However, we do need to teach this element to communicate its `primary` property and `click` event to the host version of that element. We’ll do this using the [`RemoteElement` class provided by `@remote-dom/core`](/packages/core#remoteelement): +First, we’ll create the remote environment’s version of `ui-button`. The remote version doesn’t have to worry about rendering any HTML — it’s only a signal to the host environment to render the “real” version. However, we do need to teach this element to communicate its `primary` attribute and `click` event to the host version of that element. We’ll do this using the [`RemoteElement` class provided by `@remote-dom/core`](/packages/core#remoteelement): ```html @@ -166,15 +166,12 @@ First, we’ll create the remote environment’s version of `ui-button`. The rem // for `@remote-dom/core/elements`: // https://github.com/Shopify/remote-dom/tree/main/packages/core#elements class UIButton extends RemoteElement { - static get remoteProperties() { - return { - // A boolean property can be set either by setting the attribute to a non-empty - // value, or by setting the property to `true`. - primary: {type: Boolean}, - // Remote DOM will convert the `click` event into an `onClick` property that - // is communicated to the host. - onClick: {event: true}, - }; + static get remoteAttributes() { + return ['primary']; + } + + static get remoteEvents() { + return ['click']; } } @@ -248,8 +245,6 @@ Finally, we need to provide a “real” implementation of our `ui-button` eleme return ['primary']; } - onClick; - connectedCallback() { const primary = this.hasAttribute('primary') ?? false; @@ -261,12 +256,6 @@ Finally, we need to provide a “real” implementation of our `ui-button` eleme if (primary) { root.querySelector('.Button').classList.add('Button--primary'); } - - // We’ll listen for clicks on our button, and call the remote `onClick` - // property when it happens. - root.querySelector('button').addEventListener('click', () => { - this.onClick?.(); - }); } attributeChangedCallback(name, oldValue, newValue) { diff --git a/examples/custom-element/app/index.html b/examples/custom-element/app/index.html index d14d04e5..ff4025af 100644 --- a/examples/custom-element/app/index.html +++ b/examples/custom-element/app/index.html @@ -24,8 +24,6 @@ return ['primary']; } - onClick; - connectedCallback() { const primary = this.hasAttribute('primary') ?? false; @@ -54,12 +52,6 @@ if (primary) { root.querySelector('.Button').classList.add('Button--primary'); } - - // We’ll listen for clicks on our button, and call the remote `onClick` - // property when it happens. - root.querySelector('button').addEventListener('click', () => { - this.onClick?.(); - }); } attributeChangedCallback(name, oldValue, newValue) { diff --git a/examples/custom-element/app/remote.html b/examples/custom-element/app/remote.html index e6858d55..33451511 100644 --- a/examples/custom-element/app/remote.html +++ b/examples/custom-element/app/remote.html @@ -21,14 +21,17 @@ // for `@remote-dom/core/elements`: // https://github.com/Shopify/remote-dom/tree/main/packages/core#elements class UIButton extends RemoteElement { - static get remoteProperties() { + static get remoteAttributes() { + return ['primary']; + } + + static get remoteEvents() { return { - // A boolean property can be set either by setting the attribute to a non-empty - // value, or by setting the property to `true`. - primary: {type: Boolean}, - // Remote DOM will convert the `click` event into an `onClick` property that - // is communicated to the host. - onClick: {event: true}, + click: { + dispatchEvent(detail) { + console.log(`Event detail: `, detail); + }, + }, }; } } diff --git a/examples/kitchen-sink/app/remote/elements.ts b/examples/kitchen-sink/app/remote/elements.ts index ce85fec5..940dd067 100644 --- a/examples/kitchen-sink/app/remote/elements.ts +++ b/examples/kitchen-sink/app/remote/elements.ts @@ -2,6 +2,7 @@ import { createRemoteElement, RemoteRootElement, RemoteFragmentElement, + type RemoteEvent, } from '@remote-dom/core/elements'; import type { @@ -24,23 +25,23 @@ export const Text = createRemoteElement({ }, }); -export const Button = createRemoteElement( - { - properties: { - onPress: {event: true}, - }, - slots: ['modal'], - }, -); +export const Button = createRemoteElement< + ButtonProperties, + {}, + {modal?: true}, + {press(event: RemoteEvent): void} +>({ + events: ['press'], + slots: ['modal'], +}); export const Modal = createRemoteElement< ModalProperties, ModalMethods, - {primaryAction?: true} + {primaryAction?: true}, + {open(event: RemoteEvent): void; close(event: RemoteEvent): void} >({ - properties: { - onClose: {event: true}, - }, + events: ['close'], slots: ['primaryAction'], methods: ['open', 'close'], }); diff --git a/examples/kitchen-sink/app/remote/examples/preact.tsx b/examples/kitchen-sink/app/remote/examples/preact.tsx index 8c6111f7..d1ab89ec 100644 --- a/examples/kitchen-sink/app/remote/examples/preact.tsx +++ b/examples/kitchen-sink/app/remote/examples/preact.tsx @@ -15,9 +15,18 @@ import { } from '../elements.ts'; const Text = createRemoteComponent('ui-text', TextElement); -const Button = createRemoteComponent('ui-button', ButtonElement); +const Button = createRemoteComponent('ui-button', ButtonElement, { + eventProps: { + onPress: {event: 'press'}, + }, +}); const Stack = createRemoteComponent('ui-stack', StackElement); -const Modal = createRemoteComponent('ui-modal', ModalElement); +const Modal = createRemoteComponent('ui-modal', ModalElement, { + eventProps: { + onOpen: {event: 'open'}, + onClose: {event: 'close'}, + }, +}); export function renderUsingPreact(root: Element, api: RenderAPI) { render(, root); diff --git a/examples/kitchen-sink/app/remote/examples/react.tsx b/examples/kitchen-sink/app/remote/examples/react.tsx index 62a0f647..331ed164 100644 --- a/examples/kitchen-sink/app/remote/examples/react.tsx +++ b/examples/kitchen-sink/app/remote/examples/react.tsx @@ -14,9 +14,18 @@ import { } from '../elements.ts'; const Text = createRemoteComponent('ui-text', TextElement); -const Button = createRemoteComponent('ui-button', ButtonElement); +const Button = createRemoteComponent('ui-button', ButtonElement, { + eventProps: { + onPress: {event: 'press'}, + }, +}); const Stack = createRemoteComponent('ui-stack', StackElement); -const Modal = createRemoteComponent('ui-modal', ModalElement); +const Modal = createRemoteComponent('ui-modal', ModalElement, { + eventProps: { + onOpen: {event: 'open'}, + onClose: {event: 'close'}, + }, +}); export function renderUsingReact(root: Element, api: RenderAPI) { createRoot(root).render(); diff --git a/packages/core/README.md b/packages/core/README.md index 60d02494..ba3d3381 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -32,9 +32,139 @@ class MyElement extends RemoteElement {} customElements.define('my-element', MyElement); ``` +##### Remote attributes + +You can provide Remote DOM with a list of [attributes](https://developer.mozilla.org/en-US/docs/Glossary/Attribute) that will be synchronized between the remote and host environments. This can be done manually by calling the `updateRemoteAttribute()` method in a custom `RemoteElement` subclass: + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class MyElement extends RemoteElement { + static get observedAttributes() { + return ['label']; + } + + attributeChangedCallback(name, oldValue, newValue) { + if (name === 'label') { + this.updateRemoteAttribute('label', newValue); + } + } +} + +customElements.define('my-element', MyElement); +``` + +Or, for convenience, by defining a static `remoteAttributes` getter: + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class MyElement extends RemoteElement { + static get remoteAttributes() { + return ['label']; + } +} + +customElements.define('my-element', MyElement); +``` + +Now, when we create a `my-element` element and set its `label` attribute, the change will be communicated to the host environment. + +```ts +const element = document.createElement('my-element'); +element.setAttribute('label', 'Hello, world!'); +``` + +##### Remote events + +You can also provide Remote DOM with a list of [events](https://developer.mozilla.org/en-US/docs/Web/API/Event) that will be synchronized between the remote and host environments. You can register to listen for these events on the remote element using [`addEventListener`](https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener), and they will be registered as event listeners in the host representation of the element. + +To define remote events, you can use the `remoteEvents` static getter: + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class MyElement extends RemoteElement { + static get remoteEvents() { + return ['change']; + } +} + +customElements.define('my-element', MyElement); +``` + +Now, we can create a `my-element` element and add an event listener for the `change` event dispatched by the host: + +```ts +const element = document.createElement('my-element'); +element.addEventListener('change', () => console.log('Changed!')); +``` + +By default, a `RemoteEvent` object is dispatched to your remote event listeners. This object is a subclass of [`CustomEvent`](https://developer.mozilla.org/en-US/docs/Web/API/CustomEvent/CustomEvent), and sets any argument sent from the host on the `detail` property. If you’d prefer a custom event object, you can instead use the object form of `remoteEvents` to set an event’s `dispatchEvent` option, which receives the argument from the host environment, and allows you to return a custom event that will be dispatched on the element: + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class ChangeEvent extends CustomEvent { + constructor(value) { + super('change', {detail: value}); + } +} + +class MyElement extends RemoteElement { + static get remoteEvents() { + return { + change: { + dispatchEvent(value) { + // Before calling event listeners, update some properties on the element, + // so they can be read in event listeners. + Object.assign(this, {value}); + return new ChangeEvent(value); + }, + }, + }; + } +} + +customElements.define('my-element', MyElement); + +const element = document.createElement('my-element'); +element.addEventListener('change', (event) => { + console.log('Changed!', element.value, element.value === event.detail); +}); +``` + +Remote events do not bubble by default. As an extension of this behavior, the remote element will not even request that the host inform it of a particular non-bubbling event, unless an event listener for that event is specifically added to the element. + +To listen for events in the host regardless of whether the remote element has an event listener, you can use the `bubbles` option when defining your remote event: + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class MyElement extends RemoteElement { + static get remoteEvents() { + return { + change: { + bubbles: true, + }, + }; + } +} + +customElements.define('my-element', MyElement); + +const parent = document.createElement('parent-element'); +const element = document.createElement('my-element'); +parent.append(element); + +parent.addEventListener('change', (event) => { + console.log('Nested element changed!', event.target, event.bubbles); +}); +``` + ##### Remote properties -Remote DOM converts all the important properties of an element into a dedicated object that can be communicated to the host environment. We refer to this object as an element’s “remote properties”. +Remote DOM converts an allowlist of element instance properties into a dedicated object that can be communicated to the host environment. We refer to this object as an element’s “remote properties”, and it can be used to synchronize additional state that can’t be represented by attributes or event listeners. You can manually set an element’s remote properties by using the `updateRemoteProperty()` method: @@ -125,8 +255,13 @@ Each property definition can have the following options: **`attribute`: whether this property maps to an attribute.** If `true`, which is the default, Remote DOM will set this property value from an attribute with the same name. The `type` option is used to determine how the attribute value is converted to the property value. You can choose an attribute name that differs from the property name by setting this option to a string, instead of `true`. +> **Note:** If you want to use the attribute as the “source of truth” for the property value, > you should use a [remote attribute](#remote-attributes) instead of a remote property. + **`event`: whether this property maps to an event listener.** If `true`, Remote DOM will set the property value to a function if any event listeners are set for the matching event name. +> **Note:** This feature is deprecated. You should use [`remoteEvents`](#remote-events) to define +> event listeners that will be synchronized with the host environment. + ```ts import {RemoteElement} from '@remote-dom/core/elements'; @@ -245,16 +380,16 @@ element.focus(); #### `createRemoteElement` -`createRemoteElement` lets you define a remote element class without having to subclass `RemoteElement`. Instead, you’ll just provide the remote properties and methods for your element using the `properties` and `methods` options: +`createRemoteElement` lets you define a remote element class without having to subclass `RemoteElement`. Instead, you’ll just provide the remote `properties`, `attributes`, `events`, and `methods` for your element as options to the function: ```ts import {createRemoteElement} from '@remote-dom/core/elements'; const MyElement = createRemoteElement({ + attributes: ['label'], + events: ['change'] properties: { - label: {type: String}, emphasized: {type: Boolean}, - onPress: {event: true}, }, methods: ['focus'], }); @@ -267,21 +402,32 @@ When using TypeScript, you can pass the generic type arguments to `createRemoteE ```ts import {createRemoteElement} from '@remote-dom/core/elements'; -interface MyElementProperties { +interface MyElementAttributes { label?: string; +} + +interface MyElementProperties { emphasized?: boolean; - onPress?: () => void; +} + +interface MyElementEvents { + change(event: CustomEvent): void; } interface MyElementMethods { focus(): void; } -const MyElement = createRemoteElement({ +const MyElement = createRemoteElement< + MyElementProperties, + MyElementMethods, + {}, + MyElementEvents +>({ + attributes: ['label'], + events: ['change'] properties: { - label: {type: String}, emphasized: {type: Boolean}, - onPress: {event: true}, }, methods: ['focus'], }); @@ -584,7 +730,7 @@ import {html} from '@remote-dom/core/html'; function MyButton() { return html` { + onClick=${() => { console.log('Pressed!'); }} >Click me! ` satisfies HTMLElement; ``` + +This helper uses the following logic to determine whether a given property in the template should map to an attribute, property, or event listener: + +- If the property is an instance member of the element, it will be set as a property. +- If the property is an HTML element, it will be appended as a child in a slot named the same as the property (e.g., ``}>` becomes a `ui-modal` child with a `slot="modal"` attribute). +- If the property starts with `on`, the value will be set as an event listener, with the event name being the lowercased version of the string following `on` (e.g., `onClick` sets a `click` event). +- Otherwise, the property will be set as an attribute. diff --git a/packages/core/package.json b/packages/core/package.json index 10277741..359183e7 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -80,7 +80,7 @@ "build": "rollup --config ./rollup.config.js" }, "dependencies": { - "@remote-dom/polyfill": "^1.2.0", + "@remote-dom/polyfill": "workspace:^1.2.0", "htm": "^3.1.1" }, "peerDependencies": { diff --git a/packages/core/source/connection.ts b/packages/core/source/connection.ts index 0813f597..6631c5c6 100644 --- a/packages/core/source/connection.ts +++ b/packages/core/source/connection.ts @@ -52,6 +52,7 @@ export interface RemoteConnectionHandler { id: RemoteMutationRecordUpdateProperty[1], property: RemoteMutationRecordUpdateProperty[2], value: RemoteMutationRecordUpdateProperty[3], + type?: RemoteMutationRecordUpdateProperty[4], ): void; } diff --git a/packages/core/source/constants.ts b/packages/core/source/constants.ts index 5c2bc6ae..f623ec8d 100644 --- a/packages/core/source/constants.ts +++ b/packages/core/source/constants.ts @@ -8,7 +8,13 @@ export const MUTATION_TYPE_REMOVE_CHILD = 1; export const MUTATION_TYPE_UPDATE_TEXT = 2; export const MUTATION_TYPE_UPDATE_PROPERTY = 3; +export const UPDATE_PROPERTY_TYPE_PROPERTY = 1; +export const UPDATE_PROPERTY_TYPE_ATTRIBUTE = 2; +export const UPDATE_PROPERTY_TYPE_EVENT_LISTENER = 3; + export const REMOTE_ID = Symbol.for('remote.id'); export const REMOTE_CONNECTION = Symbol.for('remote.connection'); export const REMOTE_PROPERTIES = Symbol.for('remote.properties'); +export const REMOTE_ATTRIBUTES = Symbol.for('remote.attributes'); +export const REMOTE_EVENT_LISTENERS = Symbol.for('remote.event-listeners'); export const ROOT_ID = '~'; diff --git a/packages/core/source/elements.ts b/packages/core/source/elements.ts index 049826d8..91ed3481 100644 --- a/packages/core/source/elements.ts +++ b/packages/core/source/elements.ts @@ -5,11 +5,15 @@ export { type RemoteElementPropertyType, type RemoteElementPropertyDefinition, type RemoteElementPropertiesDefinition, + type RemoteElementAttributeDefinition, + type RemoteElementEventListenerDefinition, + type RemoteElementEventListenersDefinition, type RemoteElementSlotDefinition, type RemoteElementSlotsDefinition, type RemotePropertiesFromElementConstructor, type RemoteMethodsFromElementConstructor, type RemoteSlotsFromElementConstructor, + type RemoteEventListenersFromElementConstructor, type RemoteElementCreatorOptions, } from './elements/RemoteElement.ts'; export {RemoteFragmentElement} from './elements/RemoteFragmentElement.ts'; diff --git a/packages/core/source/elements/RemoteElement.ts b/packages/core/source/elements/RemoteElement.ts index ed898825..cbc53ca3 100644 --- a/packages/core/source/elements/RemoteElement.ts +++ b/packages/core/source/elements/RemoteElement.ts @@ -1,7 +1,13 @@ -import {REMOTE_PROPERTIES} from '../constants.ts'; +import { + REMOTE_PROPERTIES, + REMOTE_ATTRIBUTES, + REMOTE_EVENT_LISTENERS, +} from '../constants.ts'; import {RemoteEvent} from './RemoteEvent.ts'; import { updateRemoteElementProperty, + updateRemoteElementAttribute, + updateRemoteElementEventListener, callRemoteElementMethod, } from './internals.ts'; @@ -22,6 +28,9 @@ export type RemoteElementPropertyTypeOrBuiltIn = export interface RemoteElementPropertyDefinition { type?: RemoteElementPropertyTypeOrBuiltIn; alias?: string[]; + /** + * @deprecated Use `RemoteElement.eventListeners` instead. + */ event?: boolean | string; attribute?: string | boolean; default?: Value; @@ -45,7 +54,22 @@ export type RemoteElementPropertiesDefinition< }; export interface RemoteElementSlotDefinition {} -interface RemoteElementSlotNormalizedDefinition {} + +export interface RemoteElementAttributeDefinition {} + +export interface RemoteElementEventListenerDefinition { + bubbles?: boolean; + dispatchEvent?: ( + this: RemoteElement, + arg: any, + ) => Event | undefined | void; +} + +export type RemoteElementEventListenersDefinition< + EventListeners extends Record = {}, +> = { + [Event in keyof EventListeners]: RemoteElementEventListenerDefinition; +}; export interface RemoteElementMethodDefinition {} @@ -62,36 +86,43 @@ export type RemoteElementMethodsDefinition< }; export type RemotePropertiesFromElementConstructor = T extends { - new (): RemoteElement; + new (): RemoteElement; } ? Properties : never; export type RemoteMethodsFromElementConstructor = T extends { - new (): RemoteElement; + new (): RemoteElement; } ? Methods : never; export type RemoteSlotsFromElementConstructor = T extends { - new (): RemoteElement; + new (): RemoteElement; } ? Slots : never; +export type RemoteEventListenersFromElementConstructor = T extends { + new (): RemoteElement; +} + ? EventListeners + : never; + export type RemoteElementConstructor< Properties extends Record = {}, Methods extends Record any> = {}, Slots extends Record = {}, + EventListeners extends Record = {}, > = { - new (): RemoteElement & Properties & Methods; + new (): RemoteElement & + Properties & + Methods; readonly remoteSlots?: | RemoteElementSlotsDefinition | readonly (keyof Slots)[]; - readonly remoteSlotDefinitions: Map< - string, - RemoteElementSlotNormalizedDefinition - >; + readonly remoteSlotDefinitions: Map; + readonly remoteProperties?: | RemoteElementPropertiesDefinition | readonly (keyof Properties)[]; @@ -99,6 +130,21 @@ export type RemoteElementConstructor< string, RemoteElementPropertyNormalizedDefinition >; + + readonly remoteAttributes?: readonly string[]; + readonly remoteAttributeDefinitions: Map< + string, + RemoteElementAttributeDefinition + >; + + readonly remoteEvents?: + | RemoteElementEventListenersDefinition + | readonly (keyof EventListeners)[]; + readonly remoteEventDefinitions: Map< + string, + RemoteElementEventListenerDefinition + >; + readonly remoteMethods?: Methods | readonly (keyof Methods)[]; createProperty( name: string, @@ -110,40 +156,66 @@ export interface RemoteElementCreatorOptions< Properties extends Record = {}, Methods extends Record = {}, Slots extends Record = {}, + EventListeners extends Record = {}, > { - slots?: RemoteElementConstructor['remoteSlots']; + slots?: RemoteElementConstructor< + Properties, + Methods, + Slots, + EventListeners + >['remoteSlots']; properties?: RemoteElementConstructor< Properties, Methods, - Slots + Slots, + EventListeners >['remoteProperties']; + attributes?: RemoteElementConstructor< + Properties, + Methods, + Slots, + EventListeners + >['remoteAttributes']; + events?: RemoteElementConstructor< + Properties, + Methods, + Slots, + EventListeners + >['remoteEvents']; methods?: RemoteElementConstructor< Properties, Methods, - Slots + Slots, + EventListeners >['remoteMethods']; } +const EMPTY_DEFINITION = Object.freeze({}); + export function createRemoteElement< Properties extends Record = {}, Methods extends Record = {}, Slots extends Record = {}, + EventListeners extends Record = {}, >({ slots, properties, + attributes, + events, methods, -}: RemoteElementCreatorOptions< - Properties, - Methods, - Slots -> = {}): RemoteElementConstructor { +}: NoInfer< + RemoteElementCreatorOptions +> = {}): RemoteElementConstructor { const RemoteElementConstructor = class extends RemoteElement< Properties, Methods, - Slots + Slots, + EventListeners > { static readonly remoteSlots = slots; static readonly remoteProperties = properties; + static readonly remoteAttributes = attributes; + static readonly remoteEvents = events; static readonly remoteMethods = methods; } as any; @@ -154,7 +226,8 @@ const REMOTE_EVENTS = Symbol('remote.events'); interface RemoteEventRecord { readonly name: string; - readonly property: string; + readonly property?: string; + readonly definition?: RemoteElementEventListenerDefinition; readonly listeners: Set; dispatch(...args: any[]): unknown; } @@ -170,11 +243,14 @@ export abstract class RemoteElement< Properties extends Record = {}, Methods extends Record any> = {}, Slots extends Record = {}, + EventListeners extends Record = {}, > extends HTMLElement { static readonly slottable = true; static readonly remoteSlots?: any; static readonly remoteProperties?: any; + static readonly remoteAttributes?: any; + static readonly remoteEvents?: any; static readonly remoteMethods?: any; static get observedAttributes() { @@ -188,10 +264,21 @@ export abstract class RemoteElement< return this.finalize().__remotePropertyDefinitions; } - static get remoteSlotDefinitions(): Map< + static get remoteAttributeDefinitions(): Map< string, - RemoteElementSlotNormalizedDefinition + RemoteElementAttributeDefinition > { + return this.finalize().__remoteAttributeDefinitions; + } + + static get remoteEventDefinitions(): Map< + string, + RemoteElementEventListenerDefinition + > { + return this.finalize().__remoteEventDefinitions; + } + + static get remoteSlotDefinitions(): Map { return this.finalize().__remoteSlotDefinitions; } @@ -203,9 +290,17 @@ export abstract class RemoteElement< string, RemoteElementPropertyNormalizedDefinition >(); + private static readonly __remoteAttributeDefinitions = new Map< + string, + RemoteElementAttributeDefinition + >(); + private static readonly __remoteEventDefinitions = new Map< + string, + RemoteElementEventListenerDefinition + >(); private static readonly __remoteSlotDefinitions = new Map< string, - RemoteElementSlotNormalizedDefinition + RemoteElementSlotDefinition >(); static createProperty( @@ -229,35 +324,65 @@ export abstract class RemoteElement< } this.__finalized = true; - const {slottable, remoteSlots, remoteProperties, remoteMethods} = this; + const { + slottable, + remoteSlots, + remoteProperties, + remoteAttributes, + remoteEvents, + remoteMethods, + } = this; // finalize any superclasses const SuperConstructor = Object.getPrototypeOf( this, ) as typeof RemoteElement; - const observedAttributes: string[] = []; - if (slottable) observedAttributes.push('slot'); + const observedAttributes = new Set(); + if (slottable) observedAttributes.add('slot'); const attributeToPropertyMap = new Map(); const eventToPropertyMap = new Map(); const remoteSlotDefinitions = new Map< string, - RemoteElementSlotNormalizedDefinition + RemoteElementSlotDefinition >(); const remotePropertyDefinitions = new Map< string, RemoteElementPropertyNormalizedDefinition >(); + const remoteAttributeDefinitions = new Map< + string, + RemoteElementAttributeDefinition + >(); + const remoteEventDefinitions = new Map< + string, + RemoteElementEventListenerDefinition + >(); if (typeof SuperConstructor.finalize === 'function') { SuperConstructor.finalize(); - observedAttributes.push(...SuperConstructor.observedAttributes); + + SuperConstructor.observedAttributes.forEach((attribute) => { + observedAttributes.add(attribute); + }); + SuperConstructor.remotePropertyDefinitions.forEach( (definition, property) => { remotePropertyDefinitions.set(property, definition); }, ); + + SuperConstructor.remoteAttributeDefinitions.forEach( + (definition, event) => { + remoteAttributeDefinitions.set(event, definition); + }, + ); + + SuperConstructor.remoteEventDefinitions.forEach((definition, event) => { + remoteEventDefinitions.set(event, definition); + }); + SuperConstructor.remoteSlotDefinitions.forEach((definition, slot) => { remoteSlotDefinitions.set(slot, definition); }); @@ -269,7 +394,7 @@ export abstract class RemoteElement< : Object.keys(remoteSlots); slotNames.forEach((slotName) => { - remoteSlotDefinitions.set(slotName, {}); + remoteSlotDefinitions.set(slotName, EMPTY_DEFINITION); }); } @@ -299,6 +424,25 @@ export abstract class RemoteElement< } } + if (remoteAttributes != null) { + remoteAttributes.forEach((attribute: string) => { + remoteAttributeDefinitions.set(attribute, EMPTY_DEFINITION); + observedAttributes.add(attribute); + }); + } + + if (remoteEvents != null) { + if (Array.isArray(remoteEvents)) { + remoteEvents.forEach((event: string) => { + remoteEventDefinitions.set(event, EMPTY_DEFINITION); + }); + } else { + Object.keys(remoteEvents).forEach((event) => { + remoteEventDefinitions.set(event, remoteEvents[event]); + }); + } + } + if (remoteMethods != null) { if (Array.isArray(remoteMethods)) { for (const method of remoteMethods) { @@ -318,7 +462,7 @@ export abstract class RemoteElement< Object.defineProperties(this, { __observedAttributes: { - value: observedAttributes, + value: [...observedAttributes], enumerable: false, }, __remoteSlotDefinitions: { @@ -329,6 +473,14 @@ export abstract class RemoteElement< value: remotePropertyDefinitions, enumerable: false, }, + __remoteAttributeDefinitions: { + value: remoteAttributeDefinitions, + enumerable: false, + }, + __remoteEventDefinitions: { + value: remoteEventDefinitions, + enumerable: false, + }, __attributeToPropertyMap: { value: attributeToPropertyMap, enumerable: false, @@ -352,7 +504,13 @@ export abstract class RemoteElement< /** @internal */ __methods?: Methods; + /** @internal */ + __eventListeners?: EventListeners; + private [REMOTE_PROPERTIES]!: Properties; + // @ts-expect-error used by helpers in the `internals.ts` file + private [REMOTE_ATTRIBUTES]!: Record; + private [REMOTE_EVENT_LISTENERS]!: Record any>; private [REMOTE_EVENTS]?: { readonly events: Map; readonly listeners: WeakMap< @@ -367,6 +525,20 @@ export abstract class RemoteElement< const propertyDescriptors: PropertyDescriptorMap = {}; + propertyDescriptors[REMOTE_ATTRIBUTES] = { + value: {}, + writable: true, + configurable: true, + enumerable: false, + }; + + propertyDescriptors[REMOTE_EVENT_LISTENERS] = { + value: {}, + writable: true, + configurable: true, + enumerable: false, + }; + const remoteProperties: Record = {}; propertyDescriptors[REMOTE_PROPERTIES] = { value: remoteProperties, @@ -408,14 +580,14 @@ export abstract class RemoteElement< Object.defineProperties(this, propertyDescriptors); } - attributeChangedCallback(key: string, _oldValue: any, newValue: any) { + attributeChangedCallback(attribute: string, _oldValue: any, newValue: any) { if ( - key === 'slot' && + attribute === 'slot' && (this.constructor as typeof RemoteElement).slottable ) { updateRemoteElementProperty( this, - key, + attribute, newValue ? String(newValue) : undefined, ); @@ -424,10 +596,16 @@ export abstract class RemoteElement< const { remotePropertyDefinitions, + remoteAttributeDefinitions, __attributeToPropertyMap: attributeToPropertyMap, } = this.constructor as typeof RemoteElement; - const property = attributeToPropertyMap.get(key); + if (remoteAttributeDefinitions.has(attribute)) { + updateRemoteElementAttribute(this, attribute, newValue); + return; + } + + const property = attributeToPropertyMap.get(attribute); const propertyDefinition = property == null ? property : remotePropertyDefinitions.get(property); @@ -440,6 +618,29 @@ export abstract class RemoteElement< ); } + connectedCallback() { + // Ensure a connection is made with the host environment, so that + // the event will be emitted even if no listener is directly attached + // to this element. + for (const [event, descriptor] of ( + this.constructor as typeof RemoteElement + ).remoteEventDefinitions.entries()) { + if (descriptor.bubbles) { + this.addEventListener(event, noopBubblesEventListener); + } + } + } + + disconnectedCallback() { + for (const [event, descriptor] of ( + this.constructor as typeof RemoteElement + ).remoteEventDefinitions.entries()) { + if (descriptor.bubbles) { + this.removeEventListener(event, noopBubblesEventListener); + } + } + } + addEventListener( type: string, listener: @@ -453,11 +654,13 @@ export abstract class RemoteElement< listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions, ) { - const {__eventToPropertyMap: eventToPropertyMap} = this - .constructor as typeof RemoteElement; + const {remoteEventDefinitions, __eventToPropertyMap: eventToPropertyMap} = + this.constructor as typeof RemoteElement; + + const listenerDefinition = remoteEventDefinitions.get(type); const property = eventToPropertyMap.get(type); - if (property == null) { + if (listenerDefinition == null && property == null) { return super.addEventListener(type, listener, options); } @@ -472,15 +675,19 @@ export abstract class RemoteElement< remoteEvent = { name: type, property, + definition: listenerDefinition, listeners: new Set(), - dispatch: (...args: any[]) => { - const event = new RemoteEvent(type, { - detail: args.length > 1 ? args : args[0], - }); + dispatch: (arg: any) => { + const event = + listenerDefinition?.dispatchEvent?.call(this, arg) ?? + new RemoteEvent(type, { + detail: arg, + bubbles: listenerDefinition?.bubbles, + }); this.dispatchEvent(event); - return event.response; + return (event as any).response; }, }; @@ -519,11 +726,11 @@ export abstract class RemoteElement< ); } - const currentPropertyValue = this[REMOTE_PROPERTIES][property]; - - if (currentPropertyValue != null) return; - - updateRemoteElementProperty(this, property!, remoteEvent.dispatch); + if (listenerDefinition) { + updateRemoteElementEventListener(this, type, remoteEvent.dispatch); + } else { + updateRemoteElementProperty(this, property!, remoteEvent.dispatch); + } } removeEventListener( @@ -546,6 +753,10 @@ export abstract class RemoteElement< updateRemoteElementProperty(this, name, value); } + updateRemoteAttribute(name: string, value?: string) { + updateRemoteElementAttribute(this, name, value); + } + callRemoteMethod(method: string, ...args: any[]) { return callRemoteElementMethod(this, method, ...args); } @@ -567,38 +778,23 @@ function removeRemoteListener( remoteEvents.events.delete(type); - if (this[REMOTE_PROPERTIES][remoteEvent.property] === remoteEvent.dispatch) { - updateRemoteElementProperty(this, remoteEvent.property, undefined); + if (remoteEvent.property) { + if ( + this[REMOTE_PROPERTIES][remoteEvent.property] === remoteEvent.dispatch + ) { + updateRemoteElementProperty(this, remoteEvent.property, undefined); + } + } else { + if (this[REMOTE_EVENT_LISTENERS][type] === remoteEvent.dispatch) { + updateRemoteElementEventListener(this, type, undefined); + } } } -// function convertPropertyValueToAttribute( -// value: Value, -// type: RemoteElementPropertyTypeOrBuiltIn, -// ) { -// switch (type) { -// case Boolean: -// return value ? '' : null; -// case Object: -// case Array: -// return value == null ? value : JSON.stringify(value); -// case String: -// case Number: -// return value == null ? value : String(value); -// case Function: -// return null; -// default: { -// return ( -// (type as RemoteElementPropertyType).serialize?.(value) ?? null -// ); -// } -// } -// } - function saveRemoteProperty( name: string, description: RemoteElementPropertyDefinition | undefined, - observedAttributes: string[], + observedAttributes: Set | string[], remotePropertyDefinitions: Map< string, RemoteElementPropertyNormalizedDefinition @@ -650,7 +846,12 @@ function saveRemoteProperty( } if (attributeName) { - observedAttributes.push(attributeName); + if (Array.isArray(observedAttributes)) { + observedAttributes.push(attributeName); + } else { + observedAttributes.add(attributeName); + } + attributeToPropertyMap.set(attributeName, name); } @@ -717,3 +918,5 @@ function convertAttributeValueToProperty( function camelToKebabCase(str: string) { return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); } + +function noopBubblesEventListener() {} diff --git a/packages/core/source/elements/internals.ts b/packages/core/source/elements/internals.ts index bfc1a1dc..a893bdd8 100644 --- a/packages/core/source/elements/internals.ts +++ b/packages/core/source/elements/internals.ts @@ -2,7 +2,12 @@ import { REMOTE_ID, REMOTE_CONNECTION, REMOTE_PROPERTIES, + REMOTE_ATTRIBUTES, + REMOTE_EVENT_LISTENERS, MUTATION_TYPE_UPDATE_PROPERTY, + UPDATE_PROPERTY_TYPE_PROPERTY, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, } from '../constants.ts'; import type {RemoteConnection, RemoteNodeSerialization} from '../types.ts'; @@ -28,6 +33,16 @@ export interface RemoteConnectedNodeProperties { * The properties to synchronize between this node and its host representation. */ [REMOTE_PROPERTIES]?: Record; + + /** + * The attributes to synchronize between this node and its host representation. + */ + [REMOTE_ATTRIBUTES]?: Record; + + /** + * The event listeners to synchronize between this node and its host representation. + */ + [REMOTE_EVENT_LISTENERS]?: Record any>; } /** @@ -51,22 +66,41 @@ export function remoteId(node: RemoteConnectedNode) { /** * Gets the remote properties of an element node. If the node is not an element - * node, this method returns `undefined`. If the element does not have any remote - * properties, this method will instead return the `attributes` of the element, - * converted into a simple object form. This makes it easy for you to represent - * “standard” HTML elements, such as `
` or ``, as remote elements. + * node, this method returns `undefined`, or if it does not have any remote properties, + * it will return undefined. */ export function remoteProperties(node: RemoteConnectedNode) { - if (node[REMOTE_PROPERTIES] != null) return node[REMOTE_PROPERTIES]; + return node[REMOTE_PROPERTIES]; +} + +/** + * Gets the remote attributes of an element node. If the node is not an element + * node, this method returns `undefined`. If the element does not have any remote + * attributes explicitly defined, this method will instead return the `attributes` + * of the element, converted into a simple object form. This makes it easy for you + * to represent “standard” HTML elements, such as `
` or ``, as remote + * elements. + */ +export function remoteAttributes(node: RemoteConnectedNode) { + if (node[REMOTE_ATTRIBUTES] != null) return node[REMOTE_ATTRIBUTES]; if ((node as any).attributes == null) return undefined; - const properties: Record = {}; + const attributes: Record = {}; for (const {name, value} of (node as Element).attributes) { - properties[name] = value; + attributes[name] = value; } - return properties; + return attributes; +} + +/** + * Gets the remote event listeners of an element node. If the node is not an element + * node, or does not have explicitly defined remote event listeners, this method returns + * `undefined`. + */ +export function remoteEventListeners(node: RemoteConnectedNode) { + return node[REMOTE_EVENT_LISTENERS]; } /** @@ -95,7 +129,89 @@ export function updateRemoteElementProperty( if (connection == null) return; connection.mutate([ - [MUTATION_TYPE_UPDATE_PROPERTY, remoteId(node), property, value], + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(node), + property, + value, + UPDATE_PROPERTY_TYPE_PROPERTY, + ], + ]); +} + +/** + * 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. + */ +export function updateRemoteElementAttribute( + node: Element, + attribute: string, + value?: string, +) { + const remoteAttributes = (node as RemoteConnectedNode)[REMOTE_ATTRIBUTES]; + + if (remoteAttributes) { + if (remoteAttributes[attribute] === value) return; + + if (value == null) { + delete remoteAttributes[attribute]; + } else { + remoteAttributes[attribute] = value; + } + } + + const connection = (node as RemoteConnectedNode)[REMOTE_CONNECTION]; + + if (connection == null) return; + + connection.mutate([ + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(node), + attribute, + value, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + ], + ]); +} + +/** + * Updates a single remote event listener 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. + */ +export function updateRemoteElementEventListener( + node: Element, + event: string, + listener?: (...args: any[]) => any, +) { + const remoteEventListeners = (node as RemoteConnectedNode)[ + REMOTE_EVENT_LISTENERS + ]; + + if (remoteEventListeners) { + if (remoteEventListeners[event] === listener) return; + + if (listener == null) { + delete remoteEventListeners[event]; + } else { + remoteEventListeners[event] = listener; + } + } + + const connection = (node as RemoteConnectedNode)[REMOTE_CONNECTION]; + + if (connection == null) return; + + connection.mutate([ + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(node), + event, + listener, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + ], ]); } @@ -148,7 +264,9 @@ export function serializeRemoteNode(node: Node): RemoteNodeSerialization { id: remoteId(node), type: nodeType, element: (node as Element).localName, - properties: Object.assign({}, remoteProperties(node)), + properties: cloneMaybeObject(remoteProperties(node)), + attributes: cloneMaybeObject(remoteAttributes(node)), + eventListeners: cloneMaybeObject(remoteEventListeners(node)), children: Array.from(node.childNodes).map(serializeRemoteNode), }; } @@ -173,6 +291,10 @@ export function serializeRemoteNode(node: Node): RemoteNodeSerialization { } } +function cloneMaybeObject(maybeObject?: T): T | undefined { + return maybeObject ? {...maybeObject} : undefined; +} + /** * Performs a method through `RemoteConnection.call()`, using the remote ID and * connection for the provided node. diff --git a/packages/core/source/html.ts b/packages/core/source/html.ts index 5754f680..11401ca1 100644 --- a/packages/core/source/html.ts +++ b/packages/core/source/html.ts @@ -53,9 +53,12 @@ export function h< childNodes.push(fragment); } else if (property in element) { (element as any)[property] = value; + } else if (property[0] === 'o' && property[1] === 'n') { + const eventName = `${property[2]!.toLowerCase()}${property.slice(3)}`; + element.addEventListener(eventName, value); } else if (value === true) { element.setAttribute(property, ''); - } else if (value === false) { + } else if (value == null || value === false) { element.removeAttribute(property); } else { element.setAttribute(property, String(value)); diff --git a/packages/core/source/index.ts b/packages/core/source/index.ts index 63751dcd..0a26b8e4 100644 --- a/packages/core/source/index.ts +++ b/packages/core/source/index.ts @@ -9,6 +9,8 @@ export { REMOTE_ID, REMOTE_CONNECTION, REMOTE_PROPERTIES, + REMOTE_ATTRIBUTES, + REMOTE_EVENT_LISTENERS, NODE_TYPE_COMMENT, NODE_TYPE_ELEMENT, NODE_TYPE_ROOT, @@ -17,4 +19,7 @@ export { MUTATION_TYPE_REMOVE_CHILD, MUTATION_TYPE_UPDATE_TEXT, MUTATION_TYPE_UPDATE_PROPERTY, + UPDATE_PROPERTY_TYPE_PROPERTY, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, } from './constants.ts'; diff --git a/packages/core/source/polyfill.ts b/packages/core/source/polyfill.ts index e69c8f37..06971531 100644 --- a/packages/core/source/polyfill.ts +++ b/packages/core/source/polyfill.ts @@ -2,10 +2,8 @@ import {Window, HOOKS, type Hooks} from '@remote-dom/polyfill'; import { REMOTE_CONNECTION, - REMOTE_PROPERTIES, MUTATION_TYPE_INSERT_CHILD, MUTATION_TYPE_REMOVE_CHILD, - MUTATION_TYPE_UPDATE_PROPERTY, MUTATION_TYPE_UPDATE_TEXT, } from './constants.ts'; import { @@ -13,6 +11,7 @@ import { connectRemoteNode, disconnectRemoteNode, serializeRemoteNode, + updateRemoteElementAttribute, type RemoteConnectedNode, } from './elements/internals.ts'; @@ -53,20 +52,25 @@ hooks.setText = (text, data) => { connection.mutate([[MUTATION_TYPE_UPDATE_TEXT, remoteId(text), data]]); }; +// When an attribute is updated, we will send a message to the host to update the +// attribute, but only for native HTML elements. Custom elements are expected to +// handle their own attribute updates (which is done automatically in the `RemoteElement` +// base class). + hooks.setAttribute = (element, name, value) => { - const callback = (element as RemoteConnectedNode)[REMOTE_CONNECTION]; - const properties = (element as RemoteConnectedNode)[REMOTE_PROPERTIES]; + // Custom elements need to define their own logic for handling attribute + // updates. + if (element.tagName.includes('-')) return; - if (callback == null || properties != null) return; + updateRemoteElementAttribute(element, name, value); +}; - callback.mutate([ - [ - MUTATION_TYPE_UPDATE_PROPERTY, - remoteId(element), - name, - value ?? undefined, - ], - ]); +hooks.removeAttribute = (element, name) => { + // Custom elements need to define their own logic for handling attribute + // updates. + if (element.tagName.includes('-')) return; + + updateRemoteElementAttribute(element, name); }; export {hooks, window, type Hooks}; diff --git a/packages/core/source/receivers/DOMRemoteReceiver.ts b/packages/core/source/receivers/DOMRemoteReceiver.ts index efec47b0..e11f2068 100644 --- a/packages/core/source/receivers/DOMRemoteReceiver.ts +++ b/packages/core/source/receivers/DOMRemoteReceiver.ts @@ -6,6 +6,10 @@ import { ROOT_ID, REMOTE_ID, REMOTE_PROPERTIES, + REMOTE_EVENT_LISTENERS, + UPDATE_PROPERTY_TYPE_PROPERTY, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, } from '../constants.ts'; import type {RemoteNodeSerialization} from '../types.ts'; import type {RemoteReceiverOptions} from './shared.ts'; @@ -125,7 +129,12 @@ export class DOMRemoteReceiver { detach(child); } }, - updateProperty: (id, property, value) => { + updateProperty: ( + id, + property, + value, + type = UPDATE_PROPERTY_TYPE_PROPERTY, + ) => { const element = attached.get(id)!; retain?.(value); @@ -134,7 +143,7 @@ export class DOMRemoteReceiver { const oldValue = remoteProperties[property]; remoteProperties[property] = value; - updateRemoteProperty(element as Element, property, value); + updateRemoteProperty(element as Element, property, value, type); release?.(oldValue); }, @@ -160,12 +169,47 @@ export class DOMRemoteReceiver { for (const property of Object.keys(node.properties)) { const value = node.properties[property]; retain?.(value); - updateRemoteProperty(normalizedChild as Element, property, value); + updateRemoteProperty( + normalizedChild as Element, + property, + value, + UPDATE_PROPERTY_TYPE_PROPERTY, + ); } } else { (normalizedChild as any)[REMOTE_PROPERTIES] = {}; } + if (node.attributes) { + for (const attribute of Object.keys(node.attributes)) { + const value = node.attributes[attribute]; + retain?.(value); + updateRemoteProperty( + normalizedChild as Element, + attribute, + value, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + ); + } + } + + if (node.eventListeners) { + (normalizedChild as any)[REMOTE_EVENT_LISTENERS] = {}; + + for (const event of Object.keys(node.eventListeners)) { + const listener = node.eventListeners[event]; + retain?.(listener); + updateRemoteProperty( + normalizedChild as Element, + event, + listener, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + ); + } + } else { + (normalizedChild as any)[REMOTE_EVENT_LISTENERS] = {}; + } + for (const child of node.children) { normalizedChild.appendChild(attach(child)); } @@ -246,14 +290,49 @@ function updateRemoteProperty( element: Element, property: string, value: unknown, + type: + | typeof UPDATE_PROPERTY_TYPE_PROPERTY + | typeof UPDATE_PROPERTY_TYPE_ATTRIBUTE + | typeof UPDATE_PROPERTY_TYPE_EVENT_LISTENER, ) { - if (property in element) { - (element as any)[property] = value; - } else if (value == null || value === false) { - element.removeAttribute(property); - } else if (value === true) { - element.setAttribute(property, ''); - } else { - element.setAttribute(property, String(value)); + switch (type) { + case UPDATE_PROPERTY_TYPE_PROPERTY: { + (element as any)[property] = value; + break; + } + case UPDATE_PROPERTY_TYPE_ATTRIBUTE: { + if (value == null) { + element.removeAttribute(property); + } else { + element.setAttribute(property, value as string); + } + + break; + } + case UPDATE_PROPERTY_TYPE_EVENT_LISTENER: { + const remoteListeners = (element as any)[REMOTE_EVENT_LISTENERS]; + const existing = remoteListeners?.[property]; + + if (existing) element.removeEventListener(property, existing); + + if (value != null) { + // Support a `RemoteEvent`-shaped event object, where the `detail` argument + // is passed to the remote environment, and the resulting promise call is passed + // to `event.resolve()`. A host implementation can use this conventional event shape + // to use the internal function representation of the event listener. + const handler = (event: any) => { + const result = (value as any)(event.detail); + event.resolve?.(result); + }; + + if (remoteListeners) { + remoteListeners[property] = handler; + } + + element.addEventListener(property, handler); + } + + break; + } } } diff --git a/packages/core/source/receivers/RemoteReceiver.ts b/packages/core/source/receivers/RemoteReceiver.ts index aeddea58..de0bd2b1 100644 --- a/packages/core/source/receivers/RemoteReceiver.ts +++ b/packages/core/source/receivers/RemoteReceiver.ts @@ -5,6 +5,9 @@ import { NODE_TYPE_ROOT, NODE_TYPE_TEXT, ROOT_ID, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + UPDATE_PROPERTY_TYPE_PROPERTY, } from '../constants.ts'; import type { RemoteTextSerialization, @@ -40,6 +43,10 @@ export interface RemoteReceiverComment extends RemoteCommentSerialization { export interface RemoteReceiverElement extends Omit { readonly properties: NonNullable; + readonly attributes: NonNullable; + readonly eventListeners: NonNullable< + RemoteElementSerialization['eventListeners'] + >; readonly children: readonly RemoteReceiverNode[]; readonly version: number; } @@ -186,14 +193,33 @@ export class RemoteReceiver { detach(removed!); }, - updateProperty: (id, property, value) => { + updateProperty: ( + id, + property, + value, + type = UPDATE_PROPERTY_TYPE_PROPERTY, + ) => { const element = attached.get(id) as Writable; retain?.(value); - const oldValue = element.properties[property]; + let updateObject: Record; + + switch (type) { + case UPDATE_PROPERTY_TYPE_PROPERTY: + updateObject = element.properties; + break; + case UPDATE_PROPERTY_TYPE_ATTRIBUTE: + updateObject = element.attributes; + break; + case UPDATE_PROPERTY_TYPE_EVENT_LISTENER: + updateObject = element.eventListeners; + break; + } + + const oldValue = updateObject[property]; - element.properties[property] = value; + updateObject[property] = value; element.version += 1; let parentForUpdate: Writable | undefined; @@ -261,8 +287,17 @@ export class RemoteReceiver { break; } case NODE_TYPE_ELEMENT: { - const {id, type, element, children, properties} = child; + const { + id, + type, + element, + children, + properties, + attributes, + eventListeners, + } = child; retain?.(properties); + retain?.(eventListeners); const resolvedChildren: RemoteReceiverNode[] = []; @@ -273,6 +308,8 @@ export class RemoteReceiver { version: 0, children: resolvedChildren as readonly RemoteReceiverNode[], properties: {...properties}, + attributes: {...attributes}, + eventListeners: {...eventListeners}, } satisfies RemoteReceiverElement; for (const grandChild of children) { @@ -296,8 +333,9 @@ export class RemoteReceiver { attached.delete(child.id); parents.delete(child.id); - if (release && 'properties' in child) { - release(child.properties); + if (release) { + if ('properties' in child) release(child.properties); + if ('eventListeners' in child) release(child.eventListeners); } if ('children' in child) { diff --git a/packages/core/source/tests/elements.test.ts b/packages/core/source/tests/elements.test.ts index be95b67f..581b3c99 100644 --- a/packages/core/source/tests/elements.test.ts +++ b/packages/core/source/tests/elements.test.ts @@ -4,18 +4,25 @@ import {describe, it, expect, vi, type MockedObject} from 'vitest'; import { RemoteElement, createRemoteElement, + RemoteEvent, // remoteProperties, // remoteProperty, RemoteRootElement, - type RemoteEvent, type RemoteElementConstructor, } from '../elements.ts'; import { RemoteReceiver, type RemoteReceiverElement, } from '../receivers/RemoteReceiver.ts'; -import {REMOTE_ID, MUTATION_TYPE_UPDATE_PROPERTY} from '../constants.ts'; -import {OWNER_DOCUMENT} from '../../../polyfill/source/constants.ts'; +import { + REMOTE_ID, + MUTATION_TYPE_UPDATE_PROPERTY, + UPDATE_PROPERTY_TYPE_PROPERTY, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + MUTATION_TYPE_INSERT_CHILD, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, +} from '../constants.ts'; +import {NAME, OWNER_DOCUMENT} from '../../../polyfill/source/constants.ts'; describe('RemoteElement', () => { describe('properties', () => { @@ -33,7 +40,7 @@ describe('RemoteElement', () => { const {root, receiver} = createAndConnectRemoteRootElement(); const name = 'Winston'; - const element = createNode(new HelloElement()); + const element = createElementFromConstructor(HelloElement); (element as HelloElementProperties).name = name; expect(receiver.connection.mutate).not.toHaveBeenCalled(); @@ -48,6 +55,8 @@ describe('RemoteElement', () => { version: 0, children: [], properties: {name}, + attributes: {}, + eventListeners: {}, }, ]); }); @@ -69,7 +78,13 @@ describe('RemoteElement', () => { (element as HelloElementProperties).name = name; expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ - [MUTATION_TYPE_UPDATE_PROPERTY, remoteId(element), 'name', name], + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'name', + name, + UPDATE_PROPERTY_TYPE_PROPERTY, + ], ]); }); @@ -84,7 +99,13 @@ describe('RemoteElement', () => { element.name = name; expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ - [MUTATION_TYPE_UPDATE_PROPERTY, remoteId(element), 'name', name], + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'name', + name, + UPDATE_PROPERTY_TYPE_PROPERTY, + ], ]); }); @@ -164,7 +185,13 @@ describe('RemoteElement', () => { expect(element.name).toBe(name); expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ - [MUTATION_TYPE_UPDATE_PROPERTY, remoteId(element), 'name', name], + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'name', + name, + UPDATE_PROPERTY_TYPE_PROPERTY, + ], ]); }); @@ -181,7 +208,13 @@ describe('RemoteElement', () => { expect(element.name).toBe(name); expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ - [MUTATION_TYPE_UPDATE_PROPERTY, remoteId(element), 'name', name], + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'name', + name, + UPDATE_PROPERTY_TYPE_PROPERTY, + ], ]); }); @@ -198,7 +231,13 @@ describe('RemoteElement', () => { expect(element.name).toBe(undefined); expect(receiver.connection.mutate).not.toHaveBeenLastCalledWith([ - [MUTATION_TYPE_UPDATE_PROPERTY, remoteId(element), 'name', name], + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'name', + name, + UPDATE_PROPERTY_TYPE_PROPERTY, + ], ]); }); @@ -216,7 +255,13 @@ describe('RemoteElement', () => { expect(element.name).toBe(undefined); expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ - [MUTATION_TYPE_UPDATE_PROPERTY, remoteId(element), 'name', undefined], + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'name', + undefined, + UPDATE_PROPERTY_TYPE_PROPERTY, + ], ]); }); @@ -238,6 +283,7 @@ describe('RemoteElement', () => { remoteId(element), 'updatedAt', updatedAt, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -261,6 +307,7 @@ describe('RemoteElement', () => { remoteId(element), 'updatedAt', updatedAt, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -283,6 +330,7 @@ describe('RemoteElement', () => { remoteId(element), 'inventory', inventory, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); @@ -295,6 +343,7 @@ describe('RemoteElement', () => { remoteId(element), 'inventory', undefined, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -317,6 +366,7 @@ describe('RemoteElement', () => { remoteId(element), 'collection', collection, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); @@ -329,6 +379,7 @@ describe('RemoteElement', () => { remoteId(element), 'collection', undefined, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -350,6 +401,7 @@ describe('RemoteElement', () => { remoteId(element), 'collection', expect.anything(), + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -374,6 +426,7 @@ describe('RemoteElement', () => { remoteId(element), 'collections', collections, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); @@ -386,6 +439,7 @@ describe('RemoteElement', () => { remoteId(element), 'collections', undefined, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -409,6 +463,7 @@ describe('RemoteElement', () => { remoteId(element), 'collection', expect.anything(), + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -441,6 +496,7 @@ describe('RemoteElement', () => { remoteId(element), 'myField', `${attributePrefix}${value}`, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -493,6 +549,7 @@ describe('RemoteElement', () => { remoteId(element), 'onPress', element.onPress, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -515,6 +572,7 @@ describe('RemoteElement', () => { remoteId(element), 'press', element.press, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -537,6 +595,7 @@ describe('RemoteElement', () => { remoteId(element), 'onPress', element.onPress, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -559,6 +618,7 @@ describe('RemoteElement', () => { remoteId(element), 'onMouseEnter', element.onMouseEnter, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -585,28 +645,6 @@ describe('RemoteElement', () => { ); }); - it('calls event listeners with a RemoteEvent containing multiple function argument as the detail', () => { - const ButtonElement = createRemoteElement<{ - onPress(...detail: any[]): void; - }>({ - properties: {onPress: {}}, - }); - - const {element} = createAndConnectRemoteElement(ButtonElement); - - const listener = vi.fn(); - element.addEventListener('press', listener); - - const detail = ['123', {hello: 'world'}]; - - element.onPress(...detail); - - expect(listener).toHaveBeenCalledWith(expect.any(CustomEvent)); - expect(listener).toHaveBeenCalledWith( - expect.objectContaining({type: 'press', detail}), - ); - }); - it('returns the resolved value attached to a RemoteEvent', () => { const ButtonElement = createRemoteElement<{ onPress(): void; @@ -660,6 +698,7 @@ describe('RemoteElement', () => { remoteId(element), 'onPress', undefined, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -685,6 +724,7 @@ describe('RemoteElement', () => { remoteId(element), 'onPress', undefined, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); @@ -711,12 +751,322 @@ describe('RemoteElement', () => { remoteId(element), 'onPress', undefined, + UPDATE_PROPERTY_TYPE_PROPERTY, ], ]); }); }); }); + describe('attributes', () => { + it('serializes initial attributes when the element is connected', () => { + const ProductElement = createRemoteElement({ + attributes: ['name'], + }); + + const receiver = new TestRemoteReceiver(); + + const root = createRemoteRootElement(); + const element = createElementFromConstructor(ProductElement); + root.append(element); + + const name = 'Fiddle leaf fig'; + element.setAttribute('name', name); + element.setAttribute('not-a-valid-attribute', 'foo'); + + root.connect(receiver.connection); + + expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ + [ + MUTATION_TYPE_INSERT_CHILD, + remoteId(root), + expect.objectContaining({ + attributes: {name}, + }), + 0, + ], + ]); + }); + + it('reflects the value of a remote attribute automatically when the attribute is set', () => { + const ProductElement = createRemoteElement({ + attributes: ['name'], + }); + + const {element, receiver} = createAndConnectRemoteElement(ProductElement); + + const name = 'Fiddle leaf fig'; + element.setAttribute('name', name); + + expect(element.getAttribute('name')).toBe(name); + // @ts-expect-error We are testing that there is no attribute reflection, and the + // type therefore also complains that `name` is not a property of `element`. + expect(element.name).toBe(undefined); + expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'name', + name, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + ], + ]); + }); + + it('reflects the value of a remote attribute automatically when the attribute is removed', () => { + const ProductElement = createRemoteElement({ + attributes: ['name'], + }); + + const {element, receiver} = createAndConnectRemoteElement(ProductElement); + + const name = 'Fiddle leaf fig'; + element.setAttribute('name', name); + element.removeAttribute('name'); + + expect(element.getAttribute('name')).toBe(null); + expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'name', + null, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + ], + ]); + }); + }); + + describe('event listeners', () => { + it('proxies event listeners, passing along the original first argument of the caller and returning the result of event.response', async () => { + const ButtonElement = createRemoteElement({ + events: ['press'], + }); + + const {element, receiver} = createAndConnectRemoteElement(ButtonElement); + + const listener = vi.fn((event: RemoteEvent) => { + event.respondWith(Promise.resolve(`Detail: ${event.detail}`)); + }); + + // We haven’t added a listener yet, so we should not have informed the host yet + expect(receiver.connection.mutate).not.toHaveBeenCalledWith([ + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'press', + expect.any(Function), + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + ], + ]); + + element.addEventListener('press', listener); + + expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'press', + expect.any(Function), + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + ], + ]); + + const dispatchFunction = receiver.get({ + id: remoteId(element), + })?.eventListeners.press; + const result = await dispatchFunction?.('Hello world'); + + expect(listener).toHaveBeenCalledWith(expect.any(RemoteEvent)); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'press', + detail: 'Hello world', + }), + ); + expect(result).toBe('Detail: Hello world'); + }); + + it('supports a `bubbles` event option that automatically listens for an event and marks it as bubbling', async () => { + const ButtonElement = createRemoteElement({ + events: { + press: { + bubbles: true, + }, + }, + }); + + const {receiver, root, element} = + createAndConnectRemoteElement(ButtonElement); + + // Attaching a listener to the root, to verify bubbling behavior. + const listener = vi.fn(); + root.addEventListener('press', listener); + + const dispatchFunction = receiver.get({ + id: remoteId(element), + })?.eventListeners.press; + await dispatchFunction?.('Hello world'); + + expect(listener).toHaveBeenCalledWith(expect.any(RemoteEvent)); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + bubbles: true, + }), + ); + }); + + it('uses a custom event provided by a `dispatchEvent()` event listener description', async () => { + class CustomRemoteEvent extends RemoteEvent {} + + const dispatchListener = vi.fn(); + + const ButtonElement = createRemoteElement({ + events: { + press: { + dispatchEvent(detail: string) { + dispatchListener(this, detail); + + return new CustomRemoteEvent('press', { + detail: `Detail: ${detail}`, + }); + }, + }, + }, + }); + + const {element, receiver} = createAndConnectRemoteElement(ButtonElement); + + const listener = vi.fn(); + + element.addEventListener('press', listener); + + expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'press', + expect.any(Function), + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + ], + ]); + + const dispatchFunction = receiver.get({ + id: remoteId(element), + })?.eventListeners.press; + await dispatchFunction?.('Hello world'); + + expect(dispatchListener).toHaveBeenCalledWith(element, 'Hello world'); + expect(listener).toHaveBeenCalledWith(expect.any(CustomRemoteEvent)); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + detail: 'Detail: Hello world', + }), + ); + }); + + it('removes an event listener when the last event listener is removed', () => { + const ButtonElement = createRemoteElement({ + events: ['press'], + }); + + const {element, receiver} = createAndConnectRemoteElement(ButtonElement); + + const firstListener = vi.fn(); + const secondListener = vi.fn(); + + element.addEventListener('press', firstListener); + + receiver.connection.mutate.mockClear(); + + element.addEventListener('press', secondListener); + + expect(receiver.connection.mutate).not.toHaveBeenCalled(); + + element.removeEventListener('press', secondListener); + + expect(receiver.connection.mutate).not.toHaveBeenCalled(); + + element.removeEventListener('press', firstListener); + + expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'press', + undefined, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + ], + ]); + + element.dispatchEvent(new RemoteEvent('press')); + + expect(firstListener).not.toHaveBeenCalled(); + expect(secondListener).not.toHaveBeenCalled(); + }); + + it('removes an event listener declared with once', () => { + const ButtonElement = createRemoteElement({ + events: ['press'], + }); + + const {element, receiver} = createAndConnectRemoteElement(ButtonElement); + + const listener = vi.fn(); + + element.addEventListener('press', listener, {once: true}); + + element.dispatchEvent(new RemoteEvent('press')); + + expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'press', + undefined, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + ], + ]); + + expect(listener).toHaveBeenCalledTimes(1); + + listener.mockClear(); + + element.dispatchEvent(new RemoteEvent('press')); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('removes an event listener declared with an abort signal', () => { + const ButtonElement = createRemoteElement({ + events: ['press'], + }); + + const {element, receiver} = createAndConnectRemoteElement(ButtonElement); + + const listener = vi.fn(); + const abort = new AbortController(); + + element.addEventListener('press', listener, {signal: abort.signal}); + + abort.abort(); + + expect(receiver.connection.mutate).toHaveBeenLastCalledWith([ + [ + MUTATION_TYPE_UPDATE_PROPERTY, + remoteId(element), + 'press', + undefined, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + ], + ]); + + element.dispatchEvent(new RemoteEvent('press')); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + describe('methods', () => { it('calls a method on the remote receiver', () => { class HelloElement extends RemoteElement<{}, {greet(): void}> { @@ -770,7 +1120,7 @@ class TestRemoteReceiver }; } - get = this.#receiver.get.bind(this.#receiver); + get: RemoteReceiver['get'] = this.#receiver.get.bind(this.#receiver); implement = this.#receiver.implement.bind(this.#receiver); subscribe = this.#receiver.subscribe.bind(this.#receiver); } @@ -779,31 +1129,43 @@ function createAndConnectRemoteElement< ElementType extends RemoteElementConstructor, >(ElementConstructor: ElementType) { const {receiver, root} = createAndConnectRemoteRootElement(); - const element = new ElementConstructor() as InstanceType; + const element = createElementFromConstructor(ElementConstructor); root.append(element); return {root, element, receiver}; } -function remoteId(node: any) { - return (node as any)[REMOTE_ID]; +function createElementFromConstructor< + ElementType extends CustomElementConstructor, +>( + ElementConstructor: ElementType, + tagName: string = 'test-custom-element', + ownerDocument: Document = window.document, +) { + const element = new ElementConstructor() as InstanceType; + + Object.defineProperties(element, { + [NAME]: {value: tagName, writable: true, enumerable: false}, + [OWNER_DOCUMENT]: { + value: ownerDocument, + writable: true, + enumerable: false, + }, + }); + + return element; } function createAndConnectRemoteRootElement() { - const root = createNode(new RemoteRootElement() as RemoteRootElement); + const root = createRemoteRootElement(); const receiver = new TestRemoteReceiver(); root.connect(receiver.connection); return {root, receiver}; } -export function createNode( - node: T, - ownerDocument: Document = window.document, -) { - Object.defineProperty(node, OWNER_DOCUMENT, { - value: ownerDocument, - writable: true, - enumerable: false, - }); +function createRemoteRootElement() { + return createElementFromConstructor(RemoteRootElement, 'remote-root'); +} - return node; +function remoteId(node: any) { + return (node as any)[REMOTE_ID]; } diff --git a/packages/core/source/types.ts b/packages/core/source/types.ts index 7aa55b1f..ab690e4c 100644 --- a/packages/core/source/types.ts +++ b/packages/core/source/types.ts @@ -6,6 +6,9 @@ import type { MUTATION_TYPE_REMOVE_CHILD, MUTATION_TYPE_UPDATE_TEXT, MUTATION_TYPE_UPDATE_PROPERTY, + UPDATE_PROPERTY_TYPE_PROPERTY, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, } from './constants.ts'; /** @@ -68,7 +71,8 @@ export type RemoteMutationRecordUpdateText = [ ]; /** - * Describes an update to the property of a remote element. + * Describes an update to the property of a remote element. A “remote property” + * represents either an HTML element property, event listener, or attribute. */ export type RemoteMutationRecordUpdateProperty = [ type: typeof MUTATION_TYPE_UPDATE_PROPERTY, @@ -81,12 +85,21 @@ export type RemoteMutationRecordUpdateProperty = [ /** * The name of the property being updated. */ - property: string, + name: string, /** * The new value of the property. */ value: unknown, + + /** + * The kind of property being updated. + * @default UPDATE_PROPERTY_TYPE_PROPERTY + */ + updateType?: + | typeof UPDATE_PROPERTY_TYPE_PROPERTY + | typeof UPDATE_PROPERTY_TYPE_EVENT_LISTENER + | typeof UPDATE_PROPERTY_TYPE_ATTRIBUTE, ]; /** @@ -148,6 +161,18 @@ export interface RemoteElementSerialization { */ readonly properties?: Record; + /** + * The attributes of the element that should be synchronized between the + * remote and host environments. + */ + readonly attributes?: Record; + + /** + * The event listeners that should be synchronized between the remote and + * host environments. + */ + readonly eventListeners?: Record any>; + /** * The list of child nodes of this element. */ diff --git a/packages/polyfill/source/Element.ts b/packages/polyfill/source/Element.ts index d351e646..299ea8d9 100644 --- a/packages/polyfill/source/Element.ts +++ b/packages/polyfill/source/Element.ts @@ -43,6 +43,10 @@ export class Element extends ParentNode { return attributes; } + getAttributeNames() { + return [...this.attributes].map((attr) => attr.name); + } + get firstElementChild() { return this.children[0] ?? null; } diff --git a/packages/polyfill/source/Event.ts b/packages/polyfill/source/Event.ts index 46b4d436..0b2390d8 100644 --- a/packages/polyfill/source/Event.ts +++ b/packages/polyfill/source/Event.ts @@ -1,4 +1,9 @@ -import {IS_TRUSTED, LISTENERS} from './constants.ts'; +import { + PATH, + IS_TRUSTED, + LISTENERS, + STOP_IMMEDIATE_PROPAGATION, +} from './constants.ts'; import type {EventTarget} from './EventTarget.ts'; export const enum EventPhase { @@ -41,12 +46,12 @@ export class Event { composed = false; defaultPrevented = false; cancelBubble = false; - immediatePropagationStopped = false; eventPhase: EventPhase = 0; - path: EventTarget[] = []; // private inPassiveListener = false; data?: any; + [PATH]: EventTarget[] = []; [IS_TRUSTED]!: boolean; + [STOP_IMMEDIATE_PROPAGATION] = false; constructor( public type: string, @@ -60,20 +65,21 @@ export class Event { } } - get composedPath() { - return this.path; - } - get isTrusted() { return this[IS_TRUSTED]; } + composedPath() { + return this[PATH]; + } + stopPropagation() { this.cancelBubble = true; } stopImmediatePropagation() { - this.immediatePropagationStopped = true; + this[STOP_IMMEDIATE_PROPAGATION] = true; + this.cancelBubble = true; } preventDefault() { @@ -98,43 +104,35 @@ export class Event { export function fireEvent( event: Event, - target: EventTarget, - phase: EventPhase, -) { - const listeners = target[LISTENERS]; + currentTarget: EventTarget, + phase: EventPhase.BUBBLING_PHASE | EventPhase.CAPTURING_PHASE, +): void { + const listeners = currentTarget[LISTENERS]; const list = listeners?.get( `${event.type}${ phase === EventPhase.CAPTURING_PHASE ? CAPTURE_MARKER : '' }`, ); - if (!list) return false; - let defaultPrevented = false; + if (!list) return; for (const listener of list) { - event.eventPhase = phase; - event.currentTarget = target; + event.eventPhase = + event.target === currentTarget ? EventPhase.AT_TARGET : phase; + event.currentTarget = currentTarget; try { if (typeof listener === 'object') { listener.handleEvent(event as any); } else { - listener.call(target, event as any); + listener.call(currentTarget, event as any); } } catch (err) { setTimeout(thrower, 0, err); } - if (event.defaultPrevented === true) { - defaultPrevented = true; - } - - if (event.immediatePropagationStopped) { - break; - } + if (event[STOP_IMMEDIATE_PROPAGATION]) break; } - - return defaultPrevented; } function thrower(error: any) { diff --git a/packages/polyfill/source/EventTarget.ts b/packages/polyfill/source/EventTarget.ts index 8f5315d0..6a5364fe 100644 --- a/packages/polyfill/source/EventTarget.ts +++ b/packages/polyfill/source/EventTarget.ts @@ -1,4 +1,4 @@ -import {HOOKS, LISTENERS, OWNER_DOCUMENT} from './constants.ts'; +import {HOOKS, PATH, LISTENERS, OWNER_DOCUMENT} from './constants.ts'; import {fireEvent, EventPhase} from './Event.ts'; import {CAPTURE_MARKER, type Event} from './Event.ts'; import type {ChildNode} from './ChildNode.ts'; @@ -105,22 +105,21 @@ export class EventTarget { // } event.target = this; event.srcElement = this; - event.path = path; - let defaultPrevented = false; - for (let i = path.length; --i; ) { - if (fireEvent(event, path[i]!, EventPhase.CAPTURING_PHASE)) { - defaultPrevented = true; - } - } - if (fireEvent(event, this, EventPhase.AT_TARGET)) { - defaultPrevented = true; + event[PATH] = path; + + for (let i = path.length; i--; ) { + fireEvent(event, path[i]!, EventPhase.CAPTURING_PHASE); + if (event.cancelBubble) return event.defaultPrevented; } - for (let i = 1; i < path.length; i++) { - if (fireEvent(event, path[i]!, EventPhase.BUBBLING_PHASE)) { - defaultPrevented = true; - } + + const bubblePath = event.bubbles ? path : path.slice(0, 1); + + for (let i = 0; i < bubblePath.length; i++) { + fireEvent(event, bubblePath[i]!, EventPhase.BUBBLING_PHASE); + if (event.cancelBubble) return event.defaultPrevented; } - return !defaultPrevented; + + return event.defaultPrevented; } } diff --git a/packages/polyfill/source/ParentNode.ts b/packages/polyfill/source/ParentNode.ts index d6901698..00902400 100644 --- a/packages/polyfill/source/ParentNode.ts +++ b/packages/polyfill/source/ParentNode.ts @@ -66,6 +66,7 @@ export class ParentNode extends ChildNode { children.splice(children.indexOf(child), 1); } + (child as any).disconnectedCallback?.(); this[HOOKS].removeChild?.(this as any, child as any, childNodesIndex); } @@ -153,6 +154,7 @@ export class ParentNode extends ChildNode { if (isElement) this.children.push(child); } + (child as any).connectedCallback?.(); this[HOOKS].insertChild?.(this as any, child as any, insertIndex); } } diff --git a/packages/polyfill/source/constants.ts b/packages/polyfill/source/constants.ts index e4116526..69652dbf 100644 --- a/packages/polyfill/source/constants.ts +++ b/packages/polyfill/source/constants.ts @@ -12,6 +12,8 @@ export const DATA = Symbol('data'); export const USER_PROPERTIES = Symbol('user_properties'); export const LISTENERS = Symbol('listeners'); export const IS_TRUSTED = Symbol('isTrusted'); +export const PATH = Symbol('path'); +export const STOP_IMMEDIATE_PROPAGATION = Symbol('stop_immediate_propagation'); export const CONTENT = Symbol('content'); export const HOOKS = Symbol('hooks'); diff --git a/packages/preact/README.md b/packages/preact/README.md index b75f76c5..1bacb505 100644 --- a/packages/preact/README.md +++ b/packages/preact/README.md @@ -27,10 +27,8 @@ import {RemoteElement} from '@remote-dom/core/elements'; // Define your remote element... // @see https://github.com/Shopify/remote-dom/tree/main/packages/core/README.md#remoteelement class MyElement extends RemoteElement { - static get remoteProperties() { - return { - label: String, - }; + static get remoteAttributes() { + return ['label']; } } @@ -44,9 +42,67 @@ render( ); ``` -However, we can make the Preact integration a bit more seamless by using the `createRemoteComponent()` function to create a wrapper Preact component. This wrapper component will automatically have the TypeScript prop types it should, given the custom element definition you pass in. More importantly, though, this wrapper will convert any Preact elements passed as props to slotted children. +However, we can make the Preact integration a bit more seamless by using the `createRemoteComponent()` function to create a wrapper Preact component. This wrapper component will automatically have the TypeScript prop types it should, given the custom element definition you pass in, and will correctly assign the props on the component to either attributes, properties, or event listeners. -For example, imagine a `ui-card` custom element that takes a `header` slot, which should be used on a `ui-heading` custom element: +```tsx +import {render} from 'preact'; + +const MyElementComponent = createRemoteComponent('my-element', MyElement); + +render( + , + document.querySelector('#root'), +); +``` + +More importantly, though, this wrapper will also take care of adapting some parts of the custom element API to be feel more natural in a Preact application. + +##### Event listener props + +Custom Preact components generally expose events as callback props on the component. To support this pattern, the `createRemoteComponent()` wrapper can map specific props on the resulting Preact component to event listeners on underlying custom element. + +Imagine a `ui-card` element with a clickable header. When clicked, the header will emit an `expand` event to the remote environment, and reveal the children of the `ui-card` element to the user. First, we define our custom element: + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class Card extends RemoteElement { + static get remoteEvents() { + return ['expand']; + } +} + +customElements.define('ui-card', Card); +``` + +Then, we use the `createRemoteComponent()` helper function to create a wrapper Preact component with an `onExpand` prop, mapped to the `expand` event: + +```tsx +import {createRemoteComponent} from '@remote-dom/preact'; + +const Card = createRemoteComponent('ui-card', CardElement, { + eventProps: { + onExpand: {event: 'expand'}, + }, +}); + +render( + { + console.log('Card expanded!'); + }} + > + This is the body of the card. + , + document.querySelector('#root'), +); +``` + +##### Slotted children to Preact elements + +The `createRemoteComponent` helper also supports mapping slotted children to Preact elements. Each top-level slot of the element’s children will be mapped to a prop with the same name on the Preact component. + +For example, our `ui-card` custom element could take a `header` slot for customizing the title of the card: ```ts import {RemoteElement} from '@remote-dom/core/elements'; @@ -57,10 +113,10 @@ class Card extends RemoteElement { } } -class Heading extends RemoteElement {} +class Text extends RemoteElement {} customElements.define('ui-card', Card); -customElements.define('ui-heading', Heading); +customElements.define('ui-text', Text); ``` The `createRemoteComponent()` wrapper will allow you to pass a `header` prop to the resulting Preact component, which can be any other Preact element: @@ -70,10 +126,10 @@ import {render} from 'preact'; import {createRemoteComponent} from '@remote-dom/preact'; const Card = createRemoteComponent('ui-card', CardElement); -const Heading = createRemoteComponent('ui-heading', HeadingElement); +const Text = createRemoteComponent('ui-text', TextElement); render( - Hello, world!}> + Hello, world!}> This is the body of the card. , document.querySelector('#root'), @@ -89,7 +145,7 @@ render( This is the body of the card. - Hello, world! + Hello, world! , document.querySelector('#root'), @@ -108,10 +164,10 @@ const Card = createRemoteComponent('ui-card', CardElement, { }, }); -const Heading = createRemoteComponent('ui-heading', HeadingElement); +const Text = createRemoteComponent('ui-text', TextElement); render( - Hello, world!}> + Hello, world!}> This is the body of the card. , document.querySelector('#root'), @@ -120,7 +176,7 @@ render( // Now, renders this tree of HTML elements: // // This is the body of the card. -// Hello, world! +// Hello, world! // ``` @@ -132,23 +188,49 @@ The `@remote-dom/preact/host` package re-exports the [`SignalRemoteReceiver` cla #### `createRemoteComponentRenderer()` -The [`RemoteRootRenderer` component](#remoterootrenderer) needs a map of which Preact components to render for each remote element. These components will receive a description of the remote element, but not much more. The `createRemoteComponentRenderer()` function can be used to create a wrapper Preact component that will automatically update whenever the properties or children of the associated remote element change. It will also provide some helpful transformations, like mapping child elements with `slot` attributes into props. +The [`RemoteRootRenderer` component](#remoterootrenderer) needs a map of which Preact components to render for each remote element. These components will receive a description of the remote element, but not much more. The `createRemoteComponentRenderer()` function can be used to create a wrapper Preact component that will automatically update whenever the properties or children of the associated remote element change. The props passed to your Preact component will be the combined result of: + +- The `properties` of the remote element +- The `attributes` of the remote element +- The `eventListeners` of the remote element, with each event listener being mapped to a prop named in the format `onEventName` +- The `children` of the remote element, where any children with a `slot` attribute are mapped to a prop with the same name ```tsx import {createRemoteComponentRenderer} from '@remote-dom/preact/host'; // Imagine we are implementing the host version of our `ui-card` custom element above, -// which allows a `header` slot. We’ll also have it accept a `subdued` property to +// which allows a `header` slot and `expand` event. We’ll also have it accept a `subdued` property to // customize its appearance. const Card = createRemoteComponentRenderer(function Card({ header, subdued, + onExpand, children, }) { + const isExpanded = useIsExpanded(); + return ( -
- {header &&
{header}
} +
+ {header && ( + + )} {children}
); diff --git a/packages/preact/source/component.tsx b/packages/preact/source/component.tsx index 49527838..a0d66c48 100644 --- a/packages/preact/source/component.tsx +++ b/packages/preact/source/component.tsx @@ -3,6 +3,7 @@ import {forwardRef} from 'preact/compat'; import type { RemoteElement, RemoteElementConstructor, + RemoteEventListenersFromElementConstructor, } from '@remote-dom/core/elements'; import type { @@ -10,7 +11,15 @@ import type { RemoteComponentTypeFromElementConstructor, } from './types.ts'; -export interface RemoteComponentOptions { +export interface RemoteComponentOptions< + Constructor extends RemoteElementConstructor< + any, + any, + any, + any + > = RemoteElementConstructor, + Props extends Record = Record, +> { /** * Customize how Preact props are mapped to slotted child elements. By default, * any prop that is listed in the remote element’s class definition, and which @@ -34,6 +43,27 @@ export interface RemoteComponentOptions { */ wrapper?: boolean | string; }; + + /** + * Customizes the props your wrapper React component will have for event listeners + * on the underlying custom element. The key is the prop name on the React component, + * and the value is an options object containing the event name on the custom element. + * + * @example + * ```tsx + * const Button = createRemoteComponent('ui-button', ButtonElement, { + * eventProps: { + * onClick: {event: 'click'}, + * }, + * }); + * ``` + */ + eventProps?: Record< + keyof Props, + { + event: keyof RemoteEventListenersFromElementConstructor; + } + >; } /** @@ -49,20 +79,26 @@ export interface RemoteComponentOptions { export function createRemoteComponent< Tag extends keyof HTMLElementTagNameMap, ElementConstructor extends RemoteElementConstructor< + any, any, any, any > = HTMLElementTagNameMap[Tag] extends RemoteElement< infer Properties, infer Methods, - infer Slots + infer Slots, + infer EventListeners > - ? RemoteElementConstructor + ? RemoteElementConstructor : never, + Props extends Record = {}, >( tag: Tag, Element: ElementConstructor | undefined = customElements.get(tag) as any, - {slotProps = true}: RemoteComponentOptions = {}, + { + slotProps = true, + eventProps = {} as any, + }: RemoteComponentOptions = {}, ): RemoteComponentTypeFromElementConstructor { const normalizeSlotProps = Boolean(slotProps); const slotPropWrapperOption = @@ -91,32 +127,31 @@ export function createRemoteComponent< continue; } - if (normalizeSlotProps) { - if ( - Element.remoteSlotDefinitions.has(prop) && - isValidElement(propValue) - ) { - if (!slotPropWrapper) { - children.push(cloneElement(propValue, {slot: prop})); - } else { - children.push( - createElement(slotPropWrapper, {slot: prop}, propValue), - ); - } - continue; + if ( + normalizeSlotProps && + Element.remoteSlotDefinitions.has(prop) && + isValidElement(propValue) + ) { + if (!slotPropWrapper) { + children.push(cloneElement(propValue, {slot: prop})); + } else { + children.push( + createElement(slotPropWrapper, {slot: prop}, propValue), + ); } + + continue; + } + + const eventProp = eventProps[prop]; + if (eventProp) { + const {event} = eventProp; + + updatedProps[`on${event as string}`] = propValue; + continue; } - // Preact assumes any properties starting with `on` are event listeners. - // If we are in this situation, we try to use one of the property’s aliases, - // which should be a name *not* starting with `on`. - const definition = Element.remotePropertyDefinitions.get(prop); - if (definition == null) continue; - const aliasTo = - definition.type === Function && definition.name.startsWith('on') - ? definition.alias?.[0] - : undefined; - updatedProps[aliasTo ?? prop] = propValue; + updatedProps[prop] = propValue; } return createElement(tag, updatedProps, ...children); diff --git a/packages/preact/source/host/hooks/props-for-element.tsx b/packages/preact/source/host/hooks/props-for-element.tsx index 7198c0c7..2d653560 100644 --- a/packages/preact/source/host/hooks/props-for-element.tsx +++ b/packages/preact/source/host/hooks/props-for-element.tsx @@ -28,9 +28,23 @@ export function usePropsForRemoteElement< ): Props | undefined { if (!element) return undefined; - const {children, properties} = element; + const {children, properties, attributes, eventListeners} = element; + const resolvedEventListeners = eventListeners.value; + const reactChildren: ReturnType[] = []; - const resolvedProperties: Record = {...properties.value}; + + const resolvedProperties: Record = { + ...properties.value, + ...attributes.value, + ...Object.keys(resolvedEventListeners).reduce>( + (listenerProps, event) => { + listenerProps[`on${event[0]!.toUpperCase()}${event.slice(1)}`] = + resolvedEventListeners[event]; + return listenerProps; + }, + {}, + ), + }; for (const child of children.value) { let slot: string | undefined = diff --git a/packages/preact/source/tests/e2e.test.tsx b/packages/preact/source/tests/e2e.test.tsx index 08b9d748..0f89375f 100644 --- a/packages/preact/source/tests/e2e.test.tsx +++ b/packages/preact/source/tests/e2e.test.tsx @@ -39,6 +39,7 @@ declare module 'vitest' { } interface ButtonProps { + tooltip?: string; disabled?: boolean; onPress?(): void; } @@ -55,10 +56,16 @@ const HostButton = forwardRef(function HostButton({ ); }); -const RemoteButtonElement = createRemoteElement({ +const RemoteButtonElement = createRemoteElement< + ButtonProps, + {}, + {}, + {press(): void} +>({ + attributes: ['tooltip'], + events: ['press'], properties: { disabled: {type: Boolean}, - onPress: {type: Function}, }, }); @@ -117,6 +124,11 @@ declare global { const RemoteButton = createRemoteComponent( 'remote-button', RemoteButtonElement, + { + eventProps: { + onPress: {event: 'press'}, + }, + }, ); const RemoteModal = createRemoteComponent('remote-modal', RemoteModalElement); @@ -150,13 +162,33 @@ describe('preact', () => { expect(rendered).toContainPreactComponent(HostButton); }); + it('can render remote DOM elements with attributes', async () => { + const receiver = new SignalRemoteReceiver(); + const mutationObserver = new RemoteMutationObserver(receiver.connection); + + const remoteRoot = document.createElement('div'); + const remoteButton = document.createElement('remote-button'); + remoteButton.setAttribute('tooltip', 'I do cool things.'); + remoteButton.textContent = 'Click me!'; + remoteRoot.append(remoteButton); + mutationObserver.observe(remoteRoot); + + const rendered = render( + , + ); + + expect(rendered).toContainPreactComponent(HostButton, { + tooltip: 'I do cool things.', + }); + }); + it('can render remote DOM elements with simple properties', async () => { const receiver = new SignalRemoteReceiver(); const mutationObserver = new RemoteMutationObserver(receiver.connection); const remoteRoot = document.createElement('div'); const remoteButton = document.createElement('remote-button'); - remoteButton.setAttribute('disabled', ''); + remoteButton.disabled = true; remoteButton.textContent = 'Disabled button'; remoteRoot.append(remoteButton); mutationObserver.observe(remoteRoot); @@ -207,9 +239,9 @@ describe('preact', () => { const remoteModal = document.createElement('remote-modal'); const remoteButton = document.createElement('remote-button'); remoteButton.slot = 'action'; - remoteButton.onPress = () => { + remoteButton.addEventListener('press', () => { remoteModal.close(); - }; + }); remoteModal.append(remoteButton); remoteRoot.append(remoteModal); mutationObserver.observe(remoteRoot); diff --git a/packages/preact/source/types.ts b/packages/preact/source/types.ts index c9495645..2020c147 100644 --- a/packages/preact/source/types.ts +++ b/packages/preact/source/types.ts @@ -27,7 +27,7 @@ export type RemoteComponentProps< * will be passed to a component that renders that element. */ export type RemoteComponentPropsFromElementConstructor< - ElementConstructor extends RemoteElementConstructor, + ElementConstructor extends RemoteElementConstructor, > = RemoteComponentProps< RemotePropertiesFromElementConstructor, RemoteMethodsFromElementConstructor, @@ -39,7 +39,9 @@ export type RemoteComponentPropsFromElementConstructor< * can be used to render that element. */ export type RemoteComponentTypeFromElementConstructor< - ElementConstructor extends RemoteElementConstructor, + ElementConstructor extends RemoteElementConstructor, + AdditionalProps extends Record = {}, > = ComponentType< - RemoteComponentPropsFromElementConstructor + RemoteComponentPropsFromElementConstructor & + AdditionalProps >; diff --git a/packages/react/README.md b/packages/react/README.md index a16c4da6..21f3eaaf 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -35,9 +35,67 @@ import {createRoot} from 'react-dom/client'; #### `createRemoteComponent()` -As of version 18, React has minimal support for custom elements. To make the React integration a bit more seamless, Remote DOM provides the `createRemoteComponent()` function to create a React wrapper component around a custom element. This wrapper component will automatically have the TypeScript prop types it should, given the custom element definition you pass in. It will also ensure React props are passed along as element properties, and will convert any React elements passed as props to slotted children. +As of version 18, React has minimal support for custom elements. To make the React integration a bit more seamless, Remote DOM provides the `createRemoteComponent()` function to create a React wrapper component around a custom element. This wrapper component will automatically have the TypeScript prop types it should, and will correctly assign the props on the component to either attributes, properties, or event listeners. -For example, imagine a `ui-card` custom element that takes a `header` slot, which should be used on a `ui-heading` custom element: +```tsx +import {createRoot} from 'react-dom/client'; +import {createRemoteComponent} from '@remote-dom/react'; + +const MyElementComponent = createRemoteComponent('my-element', MyElement); + +createRoot(document.querySelector('#root')).render( + , +); +``` + +More importantly, though, this wrapper will also take care of adapting some parts of the custom element API to be feel more natural in a Preact application. + +##### Event listener props + +Custom React components generally expose events as callback props on the component. To support this pattern, the `createRemoteComponent()` wrapper can map specific props on the resulting Preact component to event listeners on underlying custom element. + +Imagine a `ui-card` element with a clickable header. When clicked, the header will emit an `expand` event to the remote environment, and reveal the children of the `ui-card` element to the user. First, we define our custom element: + +```ts +import {RemoteElement} from '@remote-dom/core/elements'; + +class Card extends RemoteElement { + static get remoteEvents() { + return ['expand']; + } +} + +customElements.define('ui-card', Card); +``` + +Then, we use the `createRemoteComponent()` helper function to create a wrapper React component with an `onExpand` prop, mapped to the `expand` event: + +```tsx +import {createRemoteComponent} from '@remote-dom/preact'; + +const Card = createRemoteComponent('ui-card', CardElement, { + eventProps: { + onExpand: {event: 'expand'}, + }, +}); + +render( + { + console.log('Card expanded!'); + }} + > + This is the body of the card. + , + document.querySelector('#root'), +); +``` + +##### Slotted children to React elements + +The `createRemoteComponent` helper also supports mapping slotted children to React elements. Each top-level slot of the element’s children will be mapped to a prop with the same name on the React component. + +For example, our `ui-card` custom element could take a `header` slot for customizing the title of the card: ```ts import {RemoteElement} from '@remote-dom/core/elements'; @@ -48,10 +106,10 @@ class Card extends RemoteElement { } } -class Heading extends RemoteElement {} +class Text extends RemoteElement {} customElements.define('ui-card', Card); -customElements.define('ui-heading', Heading); +customElements.define('ui-text', Text); ``` The `createRemoteComponent()` wrapper will allow you to pass a `header` prop to the resulting React component, which can be any other React element: @@ -61,10 +119,10 @@ import {createRoot} from 'react-dom/client'; import {createRemoteComponent} from '@remote-dom/react'; const Card = createRemoteComponent('ui-card', CardElement); -const Heading = createRemoteComponent('ui-heading', HeadingElement); +const Text = createRemoteComponent('ui-text', TextElement); createRoot(document.querySelector('#root')).render( - Hello, world!}> + Hello, world!}> This is the body of the card. , ); @@ -79,7 +137,7 @@ createRoot(document.querySelector('#root')).render( This is the body of the card. - Hello, world! + Hello, world! , ); @@ -97,10 +155,10 @@ const Card = createRemoteComponent('ui-card', CardElement, { }, }); -const Heading = createRemoteComponent('ui-heading', HeadingElement); +const Text = createRemoteComponent('ui-text', TextElement); createRoot(document.querySelector('#root')).render( - Hello, world!}> + Hello, world!}> This is the body of the card. , ); @@ -108,7 +166,7 @@ createRoot(document.querySelector('#root')).render( // Now, renders this tree of HTML elements: // // This is the body of the card. -// Hello, world! +// Hello, world! // ``` @@ -120,7 +178,12 @@ The `@remote-dom/react/host` package re-exports the [`RemoteReceiver` class from #### `createRemoteComponentRenderer()` -The [`RemoteRootRenderer` component](#remoterootrenderer) needs a map of which React components to render for each remote element. These components will receive a description of the remote element, but not much more. The `createRemoteComponentRenderer()` function can be used to create a wrapper React component that will automatically update whenever the properties or children of the associated remote element change. It will also provide some helpful transformations, like mapping child elements with `slot` attributes into props. +The [`RemoteRootRenderer` component](#remoterootrenderer) needs a map of which React components to render for each remote element. These components will receive a description of the remote element, but not much more. The `createRemoteComponentRenderer()` function can be used to create a wrapper React component that will automatically update whenever the properties or children of the associated remote element change. The props passed to your React component will be the combined result of: + +- The `properties` of the remote element +- The `attributes` of the remote element +- The `eventListeners` of the remote element, with each event listener being mapped to a prop named in the format `onEventName` +- The `children` of the remote element, where any children with a `slot` attribute are mapped to a prop with the same name ```tsx import {createRemoteComponentRenderer} from '@remote-dom/react/host'; @@ -132,11 +195,33 @@ import {createRemoteComponentRenderer} from '@remote-dom/react/host'; const Card = createRemoteComponentRenderer(function Card({ header, subdued, + onExpand, children, }) { + const [isExpanded, setIsExpanded] = useIsExpanded(); + return ( -
- {header &&
{header}
} +
+ {header && ( + + )} {children}
); diff --git a/packages/react/source/component.tsx b/packages/react/source/component.tsx index ed68a9f8..f0b0ba0e 100644 --- a/packages/react/source/component.tsx +++ b/packages/react/source/component.tsx @@ -9,6 +9,7 @@ import { import type { RemoteElement, RemoteElementConstructor, + RemoteEventListenersFromElementConstructor, } from '@remote-dom/core/elements'; import type { @@ -16,7 +17,15 @@ import type { RemoteComponentPropsFromElementConstructor, } from './types.ts'; -export interface RemoteComponentOptions { +export interface RemoteComponentOptions< + Constructor extends RemoteElementConstructor< + any, + any, + any, + any + > = RemoteElementConstructor, + Props extends Record = Record, +> { /** * Customize how React props are mapped to slotted child elements. By default, * any prop that is listed in the remote element’s class definition, and which @@ -40,6 +49,27 @@ export interface RemoteComponentOptions { */ wrapper?: boolean | string; }; + + /** + * Customizes the props your wrapper React component will have for event listeners + * on the underlying custom element. The key is the prop name on the React component, + * and the value is an options object containing the event name on the custom element. + * + * @example + * ```tsx + * const Button = createRemoteComponent('ui-button', ButtonElement, { + * eventProps: { + * onClick: {event: 'click'}, + * }, + * }); + * ``` + */ + eventProps?: Record< + keyof Props, + { + event: keyof RemoteEventListenersFromElementConstructor; + } + >; } /** @@ -55,20 +85,26 @@ export interface RemoteComponentOptions { export function createRemoteComponent< Tag extends keyof HTMLElementTagNameMap, ElementConstructor extends RemoteElementConstructor< + any, any, any, any > = HTMLElementTagNameMap[Tag] extends RemoteElement< infer Properties, infer Methods, - infer Slots + infer Slots, + infer EventListeners > - ? RemoteElementConstructor + ? RemoteElementConstructor : never, + Props extends Record = {}, >( tag: Tag, Element: ElementConstructor | undefined = customElements.get(tag) as any, - {slotProps = true}: RemoteComponentOptions = {}, + { + slotProps = true, + eventProps = {} as any, + }: RemoteComponentOptions = {}, ): RemoteComponentTypeFromElementConstructor { const normalizeSlotProps = Boolean(slotProps); const slotPropWrapperOption = @@ -100,37 +136,55 @@ export function createRemoteComponent< continue; } - if (normalizeSlotProps) { - if ( - Element.remoteSlotDefinitions.has(prop) && - isValidElement(propValue) - ) { - if (!slotPropWrapper) { - children.push(cloneElement(propValue as any, {slot: prop})); - } else { - children.push( - createElement(slotPropWrapper, {slot: prop}, propValue), - ); - } - continue; + if ( + normalizeSlotProps && + Element.remoteSlotDefinitions.has(prop) && + isValidElement(propValue) + ) { + if (!slotPropWrapper) { + children.push(cloneElement(propValue as any, {slot: prop})); + } else { + children.push( + createElement(slotPropWrapper, {slot: prop}, propValue), + ); } - } - - const definition = Element.remotePropertyDefinitions.get(prop); - if (definition) { - remoteProperties[prop] = propValue; + continue; } + + remoteProperties[prop] = propValue; } useLayoutEffect(() => { - if (internalRef.current == null) return; + const element = internalRef.current; + if (element == null) return; + + for (const prop in remoteProperties) { + if (prop === 'children') continue; - const propsToUpdate = - lastRemotePropertiesRef.current ?? remoteProperties; + const oldValue = lastRemotePropertiesRef.current?.[prop]; + const newValue = remoteProperties[prop]; - for (const prop in propsToUpdate) { - internalRef.current[prop] = remoteProperties[prop]; + if (oldValue === newValue) continue; + + const eventProp = eventProps[prop]; + if (eventProp) { + const eventName = eventProp.event; + if (oldValue) element.removeEventListener(eventName, oldValue); + if (newValue) element.addEventListener(eventName, newValue); + continue; + } + + if (prop in element) { + element[prop] = remoteProperties[prop]; + continue; + } + + if (newValue == null) { + element.removeAttribute(prop); + } else { + element.setAttribute(prop, String(newValue)); + } } lastRemotePropertiesRef.current = remoteProperties; diff --git a/packages/react/source/host/hooks/props-for-element.tsx b/packages/react/source/host/hooks/props-for-element.tsx index 66d8e76a..950f2df8 100644 --- a/packages/react/source/host/hooks/props-for-element.tsx +++ b/packages/react/source/host/hooks/props-for-element.tsx @@ -25,13 +25,13 @@ export function usePropsForRemoteElement< ): Props | undefined { if (!element) return undefined; - const {children, properties} = element; + const {children, properties, attributes, eventListeners} = element; const reactChildren: ReturnType[] = []; const slotProperties: Record = {...properties}; for (const child of children) { - if (child.type === 1 && typeof child.properties.slot === 'string') { - const slot = child.properties.slot; + if (child.type === 1 && typeof child.attributes.slot === 'string') { + const slot = child.attributes.slot; const rendered = renderRemoteNode(child, options); slotProperties[slot] = slotProperties[slot] ? ( <> @@ -48,6 +48,15 @@ export function usePropsForRemoteElement< return { ...properties, + ...attributes, + ...Object.keys(eventListeners).reduce>( + (listenerProps, event) => { + listenerProps[`on${event[0]!.toUpperCase()}${event.slice(1)}`] = + eventListeners[event]; + return listenerProps; + }, + {}, + ), ...slotProperties, children: reactChildren, } as unknown as Props; diff --git a/packages/react/source/types.ts b/packages/react/source/types.ts index 6815b7ac..dba74d59 100644 --- a/packages/react/source/types.ts +++ b/packages/react/source/types.ts @@ -27,7 +27,7 @@ export type RemoteComponentProps< * will be passed to a component that renders that element. */ export type RemoteComponentPropsFromElementConstructor< - ElementConstructor extends RemoteElementConstructor, + ElementConstructor extends RemoteElementConstructor, > = RemoteComponentProps< RemotePropertiesFromElementConstructor, RemoteMethodsFromElementConstructor, @@ -39,7 +39,9 @@ export type RemoteComponentPropsFromElementConstructor< * can be used to render that element. */ export type RemoteComponentTypeFromElementConstructor< - ElementConstructor extends RemoteElementConstructor, + ElementConstructor extends RemoteElementConstructor, + AdditionalProps extends Record = {}, > = ComponentType< - RemoteComponentPropsFromElementConstructor + RemoteComponentPropsFromElementConstructor & + AdditionalProps >; diff --git a/packages/signals/source/SignalRemoteReceiver.ts b/packages/signals/source/SignalRemoteReceiver.ts index 32013717..91dd49c4 100644 --- a/packages/signals/source/SignalRemoteReceiver.ts +++ b/packages/signals/source/SignalRemoteReceiver.ts @@ -1,4 +1,9 @@ -import {signal, batch, type ReadonlySignal} from '@preact/signals-core'; +import { + signal, + batch, + type ReadonlySignal, + type Signal, +} from '@preact/signals-core'; import { ROOT_ID, @@ -6,6 +11,9 @@ import { NODE_TYPE_ELEMENT, NODE_TYPE_COMMENT, NODE_TYPE_TEXT, + UPDATE_PROPERTY_TYPE_PROPERTY, + UPDATE_PROPERTY_TYPE_ATTRIBUTE, + UPDATE_PROPERTY_TYPE_EVENT_LISTENER, createRemoteConnection, type RemoteConnection, type RemoteNodeSerialization, @@ -38,10 +46,19 @@ export interface SignalRemoteReceiverComment * the `properties` and `children` properties each wrapped in a signal. */ export interface SignalRemoteReceiverElement - extends Omit { + extends Omit< + RemoteElementSerialization, + 'children' | 'properties' | 'attributes' | 'eventListeners' + > { readonly properties: ReadonlySignal< NonNullable >; + readonly attributes: ReadonlySignal< + NonNullable + >; + readonly eventListeners: ReadonlySignal< + NonNullable + >; readonly children: ReadonlySignal; } @@ -158,18 +175,38 @@ export class SignalRemoteReceiver { detach(removed!); }, - updateProperty: (id, property, value) => { + updateProperty: ( + id, + property, + value, + type = UPDATE_PROPERTY_TYPE_PROPERTY, + ) => { const element = attached.get(id) as SignalRemoteReceiverElement; - const oldProperties = element.properties.peek(); - const oldValue = oldProperties[property]; + + let updateSignal: Signal>; + + switch (type) { + case UPDATE_PROPERTY_TYPE_PROPERTY: + updateSignal = element.properties; + break; + case UPDATE_PROPERTY_TYPE_ATTRIBUTE: + updateSignal = element.attributes; + break; + case UPDATE_PROPERTY_TYPE_EVENT_LISTENER: + updateSignal = element.eventListeners; + break; + } + + const oldUpdateObject = updateSignal.peek(); + const oldValue = oldUpdateObject[property]; if (Object.is(oldValue, value)) return; retain?.(value); - const newProperties = {...oldProperties}; - newProperties[property] = value; - (element.properties as any).value = newProperties; + const newUpdateObject = {...oldUpdateObject}; + newUpdateObject[property] = value; + updateSignal.value = newUpdateObject; // If the slot changes, inform parent nodes so they can // re-parent it appropriately. @@ -223,8 +260,17 @@ export class SignalRemoteReceiver { break; } case NODE_TYPE_ELEMENT: { - const {id, type, element, children, properties} = child; + const { + id, + type, + element, + children, + properties, + attributes, + eventListeners, + } = child; retain?.(properties); + retain?.(eventListeners); const resolvedChildren: SignalRemoteReceiverNode[] = []; @@ -236,6 +282,8 @@ export class SignalRemoteReceiver { resolvedChildren as readonly SignalRemoteReceiverNode[], ), properties: signal(properties ?? {}), + attributes: signal(attributes ?? {}), + eventListeners: signal(eventListeners ?? {}), } satisfies SignalRemoteReceiverElement; for (const grandChild of children) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f9a11735..8225f99b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,7 +124,7 @@ importers: packages/core: dependencies: '@remote-dom/polyfill': - specifier: ^1.2.0 + specifier: workspace:^1.2.0 version: link:../polyfill htm: specifier: ^3.1.1