Skip to content

Latest commit

 

History

History
753 lines (529 loc) · 30 KB

README.md

File metadata and controls

753 lines (529 loc) · 30 KB

@remote-dom/core

A collection of DOM-based utilities for synchronizing elements between JavaScript environments.

Installation

npm install @remote-dom/core --save # npm
pnpm install @remote-dom/core --save # pnpm
yarn add @remote-dom/core # yarn

Usage

@remote-dom/core/elements

The @remote-dom/core/elements package provides the classes and utility functions required to define “remote” elements. You’ll use these utilities in the sandboxed JavaScript environment that’s sending elements.

To import this entry, you must be in an environment with browser globals, including HTMLElement and MutationObserver. If you want to run your remote environment in a web worker, you can use the minimal DOM polyfill provided by @remote-dom/core/polyfill

RemoteElement

The most important of these utilities is RemoteElement, which is a base class for defining elements in the remote environment. This class is a subclass of HTMLElement, and adds the ability to declare how properties and methods are synchronized between the remote and host environments.

To define a remote element, the simplest approach is to subclass RemoteElement, and to use the customElements global to associate this element with a tag name:

import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {}

customElements.define('my-element', MyElement);
Remote attributes

You can provide Remote DOM with a list of attributes 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:

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:

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.

const element = document.createElement('my-element');
element.setAttribute('label', 'Hello, world!');
Remote events

You can also provide Remote DOM with a list of events that will be synchronized between the remote and host environments. You can register to listen for these events on the remote element using 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:

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:

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, 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:

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:

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 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:

import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
  #label;

  get label() {
    return this.#label;
  }

  set label(value) {
    this.#label = value;
    this.updateRemoteProperty('label', value);
  }
}

customElements.define('my-element', MyElement);

Now, when we construct a my-element element and set its label property, the change will be communicated to the host environment.

const element = document.createElement('my-element');
element.label = 'Hello, world!';

Manually updating remote properties can get a little tedious. Additionally, it’s generally expected that properties can also be set as attributes, which makes it easier to construct elements using HTML. Remote DOM lets you create these attribute/ property pairs easily by indicating the name of your properties in the remoteProperties static getter:

import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
  static get remoteProperties() {
    return ['label'];
  }
}

customElements.define('my-element', MyElement);

Now, we can set the label property as an attribute or property, and in either case, the change will be communicated to the host environment:

const element = document.createElement('my-element');
element.setAttribute('label', 'Hello, world!');

// Or, you can use HTML to create the element and set its attribute
const template = document.createElement('template');
template.innerHTML = '<my-element label="Hello, world!"></my-element>';

Remote DOM allows you to define more complex remote properties that do not map to simple string attributes. Instead of setting remoteProperties to an array of property names, you can instead set it to an object that provides more details on how to coordinate the attribute, property, and remote property values:

import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
  static get remoteProperties() {
    return {
      label: {type: String},
      emphasized: {type: Boolean},
      onPress: {event: true},
    };
  }
}

customElements.define('my-element', MyElement);

const element = document.createElement('my-element');
element.setAttribute('label', 'Hello, world!');
element.emphasized = true;
element.addEventListener('press', () => console.log('Pressed!'));

Each property definition can have the following options:

type: The type of the property. This is used to convert the attribute value to the property value, and vice versa. You can pass any of the following values for this option:

  • String: The default type. The property value is a string, and will be directly mirrored between attribute and property values.
  • Number: Converts an attribute value to a number before assigning it to the property.
  • Boolean: Converts an attribute value to a boolean before assigning it to the property. If the attribute is present, the property will be true; otherwise, it will be false.
  • Array or Object: Processes an attribute with JSON.parse() before assigning it to the property.
  • Function: Prevents the attribute from being assigned.
  • An object with optional parse() and serialize() methods, which are used to convert the attribute value to the property value, and to serialize the property value to a remote property, respectively.

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 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 to define event listeners that will be synchronized with the host environment.

import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
  static get remoteProperties() {
    return {
      onPress: {event: true},
    };
  }
}

customElements.define('my-element', MyElement);

const element = document.createElement('my-element');

// Adding an event listener that maps to the `onPress` property:
element.addEventListener('press', () => console.log('Pressed!'));

// Alternatively, directly setting the remote property:
element.onPress = () => console.log('Pressed!');

The event name is the name of the property with the on prefix removed, and converted to kebab-case. For example, onPressStart would be mapped to a press-start event. Alternatively, you can set the event option to a string to explicitly set the event name:

import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
  static get remoteProperties() {
    return {
      onPressStart: {event: 'pressstart'},
    };
  }
}

customElements.define('my-element', MyElement);

const element = document.createElement('my-element');

element.addEventListener('pressstart', () => console.log('Pressed!'));

When a remote element uses event listeners to define remote properties, those event listeners will be called with a special RemoteEvent object. This object is like the normal Event object, but it has a few special properties:

  • detail: set to the first argument passed by the caller of the remote property.
  • response: set to the last value passed to the respondWith() method. After all event listeners have run, this value is returned to the caller of the remote property.
  • respondWith(): Sets a value to be returned to the caller of the remote property.
import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
  static get remoteProperties() {
    return {
      onSave: {event: true},
    };
  }
}

customElements.define('my-element', MyElement);

const element = document.createElement('my-element');

element.addEventListener('save', (event) => {
  // Argument passed to the `onSave()` remote property
  console.log(event.detail);

  // Return a promise
  event.respondWith(
    (async () => {
      // Do something asynchronous
      await doSomething();

      // Return a value to the caller of the remote property
      return {success: true};
    })(),
  );
});
Remote methods

Remote DOM also lets you define methods in the host environment that can be called from the remote environment. You can call these methods using the callRemoteMethod() function:

import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
  focus() {
    return this.callRemoteMethod('focus');
  }
}

customElements.define('my-element', MyElement);

const element = document.createElement('my-element');
element.focus();

It’s common that a method in your RemoteElement subclass will just call through to a remote method with a matching name, like the focus() method above. In those cases, you can instead define a remoteMethods static getter to automatically create these methods:

import {RemoteElement} from '@remote-dom/core/elements';

class MyElement extends RemoteElement {
  static get remoteMethods() {
    return ['focus'];
  }
}

customElements.define('my-element', MyElement);

const element = document.createElement('my-element');
element.focus();

createRemoteElement

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:

import {createRemoteElement} from '@remote-dom/core/elements';

const MyElement = createRemoteElement({
  attributes: ['label'],
  events: ['change']
  properties: {
    emphasized: {type: Boolean},
  },
  methods: ['focus'],
});

customElements.define('my-element', MyElement);

When using TypeScript, you can pass the generic type arguments to createRemoteElement to define the property and method types for your element. This ensures that, when you create your element instance, the properties and methods are properly typed:

import {createRemoteElement} from '@remote-dom/core/elements';

interface MyElementAttributes {
  label?: string;
}

interface MyElementProperties {
  emphasized?: boolean;
}

interface MyElementEvents {
  change(event: CustomEvent): void;
}

interface MyElementMethods {
  focus(): void;
}

const MyElement = createRemoteElement<
  MyElementProperties,
  MyElementMethods,
  {},
  MyElementEvents
>({
  attributes: ['label'],
  events: ['change']
  properties: {
    emphasized: {type: Boolean},
  },
  methods: ['focus'],
});

customElements.define('my-element', MyElement);

RemoteMutationObserver

Remote DOM needs some way to detect that changes have happened in a remote element, in order to communicate those changes to the host environment. If you’re polyfilling the DOM with @remote-dom/core/polyfill, this is handled for you. However, when operating in other environments, like an iframe with a native DOM, you’ll need something that can track these changes.

The RemoteMutationObserver class builds on the browser’s MutationObserver to detect changes in a remote element, and to communicate those changes in a way that Remote DOM can understand. You create this object from a “remote connection”, which you’ll generally get from the @remote-dom/core/receiver package. Then, you’ll observe changes in the HTML element that contains your tree of remote elements.

import {RemoteMutationObserver} from '@remote-dom/core/elements';

const observer = new RemoteMutationObserver(connection);

// Now, any changes to the `body` element will be communicated
// to the host environment.
observer.observe(document.body);

RemoteRootElement

The RemoteRootElement is a custom HTMLElement subclass that can be used to define the root of a tree of custom elements that will be synchronized with the host environment. Unlike RemoteMutationObserver, RemoteRootElement only works in an environment polyfilled using @remote-dom/core/polyfill. Once created, you should pass a “remote connection” to the connect() method, which will start the synchronization process:

import {RemoteRootElement} from '@remote-dom/core/elements';

// Remote DOM does not define this element, so you can give it a
// name of your choice. We recommend using `remote-root`.

customElements.define('remote-root', RemoteRootElement);

const root = document.createElement('remote-root');

// Now, any changes to this elements descendants will be communicated
// to the host environment.
root.connect(connection);

BatchingRemoteConnection

The RemoteConnection object you receive from RemoteReceiver.connection is a simple object that immediately communicates all updates to the host environment. When using RemoteMutationObserver, documented above, this is not a major issue, since the MutationObserver API automatically batches DOM mutations. However, it can be more of a problem when using Remote DOM in a web worker (typically, with the RemoteRootElement wrapper), where no such batching is performed.

To improve performance in these cases, you can use the BatchingRemoteConnection class, which batches updates from the remote environment that happen in the same JavaScript task. This class is a subclass of RemoteConnection, and can be used directly in place of the original connection object:

import {
  BatchingRemoteConnection,
  RemoteRootElement,
} from '@remote-dom/core/elements';

customElements.define('remote-root', RemoteRootElement);

const root = document.createElement('remote-root');

root.connect(new BatchingRemoteConnection(connection));

RemoteFragmentElement

Some APIs in @remote-dom/preact and @remote-dom/react need to create an HTML element as a generic container. This element is not defined by default, so if you use these features, you must define a matching custom element for this container. Remote DOM calls this element remote-fragment, and you can define this element using the RemoteFragmentElement constructor:

import {RemoteFragmentElement} from '@remote-dom/core/elements';

customElements.define('remote-fragment', RemoteFragmentElement);

@remote-dom/core/receiver

A “remote receiver” collects updates that happened in a remote environment, and reconstructs them in a way that allows them to be rendered in the host environment.

This library provides two kinds of receiver: RemoteReceiver, which converts the remote elements into a basic JavaScript representation, and DOMRemoteReceiver, which converts remote elements into matching DOM elements.

RemoteReceiver

A RemoteReceiver stores remote elements into a basic JavaScript representation, and allows subscribing to individual elements in the remote environment. This can be useful for mapping remote elements to components in a JavaScript framework; for example, the @remote-dom/react library uses this receiver to map remote elements to React components.

An empty remote receiver can be created using the RemoteReceiver constructor:

import {RemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new RemoteReceiver();

To support functions being passed over postMessage, you may need a way to manually manage memory for remote properties as they are received. RemoteReceiver lets you accomplish this by passing the retain and release options to the constructor, which are called when new remote properties are received and when they are overwritten, respectively:

// This library is not included with Remote DOM, but it pairs
// well with it in allowing you to pass functions between
// JavaScript environments without leaking memory, by manually
// managing the memory for those functions.
import {retain, release} from '@quilted/threads';
import {RemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new RemoteReceiver({retain, release});
RemoteReceiver.connection

Each RemoteReceiver has a connection property, which can be passed to a RemoteMutationObserver or RemoteRootElement in the remote environment. This object, which the library refers to as a RemoteConnection, is responsible for communicating changes between the remote environment and host environments.

// In the host environment:
import {RemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new RemoteReceiver();

// In the remote environment:
import {RemoteMutationObserver} from '@remote-dom/core/elements';

const observer = new RemoteMutationObserver(receiver.connection);
RemoteReceiver.root

Each RemoteReceiver also has a root property, which defines the object that all remote element representations will be attached to. This object has a children property, which will contain child text and element nodes, which may themselves have additional children.

import {RemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new RemoteReceiver();
const root = receiver.root;
// {
//   children: [],
//   version: 0,
//   ...
// }
RemoteReceiver.subscribe()

RemoteReceiver.subscribe() allows you to subscribe to changes in a remote element. This includes changes to the remote element’s properties and list of children, but note that you will not receive updates for properties or children of nested elements.

The first argument to this function is the remote element you want to subscribe to, and the second is a function that will be called with the updated description of that element on each change:

import {RemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new RemoteReceiver();

// Subscribe to all changes in the top-level children, attached
// directly to the remote “root”.
receiver.subscribe(receiver.root, (root) => {
  console.log('Root changed!', root);
});

You can pass a third options argument to the subscribe() method. Currently, only one option is available: signal, which lets you pass an AbortSignal that will be used to cancel the subscription:

import {RemoteReceiver} from '@remote-dom/core/receivers';

const abort = new AbortController();
const receiver = new RemoteReceiver();

// Subscribe to all changes in the top-level children, attached
// directly to the remote “root”.
receiver.subscribe(
  receiver.root,
  (root) => {
    console.log('Root changed!', root);
  },
  {signal: abort.signal},
);

// Stop listening in 10 seconds
setTimeout(() => {
  abort.abort();
}, 10_000);
RemoteReceiver.implement()

RemoteReceiver.implement() lets you define how remote methods are implemented for a particular element. The first argument to this method is the element you want to implement methods for, and the second is an object that provides the implementation for each supported method.

For example, in the example below, we implement a alert() method on the root element, which can then be called from the remote environment:

// In the host environment:
import {RemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new RemoteReceiver();

receiver.implement(receiver.root, {
  alert(message) {
    window.alert(message);
  },
});

// In the remote environment:
import {RemoteRootElement} from '@remote-dom/core/elements';

customElements.define('remote-root', RemoteRootElement);

const root = document.createElement('remote-root');
root.connect(receiver.connection);

root.callRemoteMethod('alert', 'Hello, world!');
RemoteReceiver.get()

RemoteReceiver.get() fetches the latest state of a remote element that has been received from the remote environment.

import {RemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new RemoteReceiver();

receiver.get(receiver.root) === receiver.root; // true

DOMRemoteReceiver

DOMRemoteReceiver takes care of mapping remote elements to matching HTML elements on the host page. If you implement your UI with custom elements, DOMRemoteReceiver is a simple option that avoids much of the manual work required when using the basic RemoteReceiver.

An empty remote receiver can be created using the DOMRemoteReceiver constructor. You’ll then call the connect() method with the HTML element that will serve as your “root” element, to which all the synchronized remote elements will be attached:

import {DOMRemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new DOMRemoteReceiver();

// Any custom elements created in the remote environment will
// be attached to the `body` element.
receiver.connect(document.body);

Like with RemoteReceiver, you can pass the retain and release options to the constructor to manually manage memory for remote properties as they are received:

// This library is not included with Remote DOM, but it pairs
// well with it in allowing you to pass functions between
// JavaScript environments without leaking memory, by manually
// managing the memory for those functions.
import {retain, release} from '@quilted/threads';
import {DOMRemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new DOMRemoteReceiver({retain, release});
Caching DOM nodes

By default, DOMRemoteReceiver will create a new DOM node each time a remote element is attached to a new parent, which is done to release memory related to the remote environment as quickly as possible. However, this can be inefficient if you’re frequently moving elements between different parents, as this “re-parenting” will create separate elements on the host page each time the parent is changed. If this is a case you need to optimize for, you can pass the cache.maxAge option to the DOMRemoteReceiver constructor, which will re-use an existing host element representing a remote element when the remote element is re-attached within the specified number of milliseconds:

import {DOMRemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new DOMRemoteReceiver({
  // Preserve host elements for 1 second after they are
  // detached from the tree
  cache: {maxAge: 1_000},
});
DOMRemoteReceiver.connection

Like RemoteReceiver, each DOMRemoteReceiver has a connection property, which can be passed to a RemoteMutationObserver or RemoteRootElement in the remote environment.

// In the host environment:
import {DOMRemoteReceiver} from '@remote-dom/core/receivers';

const receiver = new DOMRemoteReceiver();

// In the remote environment:
import {RemoteMutationObserver} from '@remote-dom/core/elements';

const observer = new RemoteMutationObserver(receiver.connection);
DOMRemoteReceiver.root

Each DOMRemoteReceiver has a root property. If you’ve called connect() on your receiver, this property will be the HTML element that you passed to that method. Otherwise, it will be a DocumentFragment that stores remote elements before you’ve selected the host element to attach them to.

@remote-dom/core/polyfill

The @remote-dom/core/polyfill package provides a minimal DOM polyfill that can be used to run remote elements in a web worker, and automatically communicates changes in that DOM to a host environment, if it has been connected by a RemoteRootElement. This polyfill builds on top of the small, hook-able DOM polyfill provided by @remote-dom/polyfill.

To use this polyfill, import it before any other code that might depend on DOM globals:

import '@remote-dom/core/polyfill';
import {RemoteElement} from '@remote-dom/core/elements';

// ...

@remote-dom/core/html

The @remote-dom/core/html package provides a helper function for creating DOM elements from tagged template literals. This lets you create large quantities of DOM elements, with intelligent handling of element properties, and supports minimal “components” for packaging up reusable DOM structures.

import {html} from '@remote-dom/core/html';

function MyButton() {
  return html`<ui-button
    onClick=${() => {
      console.log('Pressed!');
    }}
    >Click me!</ui-button
  >`;
}

const html = html`
  <ui-stack spacing>
    <ui-text>Hello, world!</ui-text>
    <${MyButton} />
  </ui-stack>
` 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., <ui-button modal=${html}> 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.