Skip to content

Latest commit

 

History

History
521 lines (415 loc) · 26.5 KB

DOM-Parts.md

File metadata and controls

521 lines (415 loc) · 26.5 KB

DOM Part API - First Step of Template Instantiation

This is a joint proposal by Ryosuke Niwa and Theresa O'Connor at Apple and Justin Fagnani, Mason Freed, and Yuzhe Han at Google.

Motivation

The HTML5 specification defines the template element but doesn't provide a native mechanism to instantiate it with some parts of it substituted, conditionally included, or repeated based on JavaScript values — unlike popular JavaScript frameworks such as Ember.js and Angular allow.

In 2017, Apple proposed the Template Instantiation API, a Web API to parameterize a template instance and allow interpolation of JavaScript data during the initial instantiation as well as in subsequent updates.

Reflecting on many feedbacks we've received including whether such as us having to ensure API materially improves the platform at all, this refined proposal focuses on the essence of the previous proposal: NodeTemplatePart and AttributeTemplatePart which provide mechanisms to insert or replace content at a specific location in a DOM tree. We've heard from multiple library and framework authors that such API, if designed right, can be adopted and reduce the amount of code they have to write. The fact there is a similar proposal made by a framework author gives us confidence that extracting the essence of NodeTemplatePart and AttributeTemplatePart would bring a material benefit to the platform while paving our way for the full declarative syntax for custom elements.

As such, this proposal foregoes the previously controversial parts of the proposals: a standard syntax for data interpolations, conditionals, and loops, and a mechanism to define different types of template processing model that are needed to address all the use cases mentioned in the old proposal to a future extension.

Proposal

We introduce the concept of a DOM part, which represents an unit of mutable state in a DOM tree. It could be a content attribute of an element, child nodes of a node, or even a JavaScript property of an element. A DOM part is represented by the Part interface. It has one property named "value" of any type.

When a new value is set or assigned to a DOM part, the change does not immediately reflect back to the corresponding node, its attributes, or its properties Instead, the new value is staged to be later committed in a batch, which is defined by an ordered set of DOM parts called DOM parts group. Each DOM part may belong to at most one DOM part group. This batching reduces the runtime overhead of constantly returning control back from browser's implementation to JavaScript between each DOM mutation and allows browser engine's to avoid or batch certain sanity checks and housekeeping tasks.

In the old proposal, a DOM part group was represented by a template element. This proposal keeps that approach possible but does not keep it the sole approach possible.

Interfaces

interface Part {
    attribute any value;
    void commit();
};

interface NodePart : Part {
    readonly attribute Node node;
};

interface AttributePart : Part {
    constructor(Element element, DOMString qualifiedName, DOMString? namespace);
    readonly attribute DOMString prefix;
    readonly attribute DOMString localName;
    readonly attribute DOMString namespaceURI;
};

interface ChildNodePart : Part {
    constructor(Node node, Node? previousSibling, Node? nextSibling);
    readonly attribute Node parentNode;
    readonly attribute Node? previousSibling;
    readonly attribute Node? nextSibling;
};

In the most basic level, this proposal consists of two DOM parts: AttributePart represents a single attribute which can be set and ChildNodePart represents a sequence of child nodes of a node which can be replaced.

Basic Examples

Suppose we had the following template where {name} and {email} are locations of our interests:

<section><h1 id="name">{name}</h1>Email: <a id="link" href="mailto:{email}">{email}</a></section>

And we've parsed the following HTML so far (future extensions will make this parsing possible without extra JavaScript code):

<section><h1 id="name"></h1>Email: <a id="link" href=""></a></section>

Then we can create ChildNodePart for h1 element and a element and AttributePart for a element as follows:

namePart = new ChildNodePart(name)
emailPart = new ChildNodePart(link)
emailAttributePart = new AttributePart(link, 'href')

Then assigning values as follows will update the DOM:
namePart.value = "Ryosuke Niwa"
emailPart.value = "rniwa@webkit.org"
emailAttributePart.value = "mailto:rniwa@webkit.org"
namePart.commit();
emailPart.commit();
emailAttributePart.commit();

The resultant DOM will look like this:

<section><h1 id="name">Ryosuke Niwa</h1>Email: <a id="link" href="mailto:rniwa@webkit.org"></a></section>

Example with Templates

Suppose we had the following template:

<template id="t1"><section><h1 id="name">{name}</h1>Email: <a id="link" href="{emailAddress}">{email}</a></section></template>

We can create clone its content and create DOM parts as follows:

const instance = document.importNode(t1.content, /* deep */ true);
const parts = createParts(instance);
container.appendChild(instance);

function createParts(node, parts = {}) {
    const add = (replaceable, part) => {
        parts[replaceable.substring(1, expression.length - 1)] = part;
    }
    if (node.nodeType == Node.TEXT_NODE && node.data.match(/^\{\w+\}$/)) {
        add(node.data, new ChildNodePart(node.parentNode, node.previousSibling, node.nextSibling));
        node.remove();
        return;
    }
    for (const attr of (node.attributes || [])) {
        if (attr.value.match(/^\{\w+\}$/)) {
            add(attr.value, new AttributePart(node, attr.name, attr.namespaceURI);
            attr.value = ';
        }
    }
    for (const childNode of node.childNodes)
        createParts(childNode, parts);
    return parts;
}

After running this code, instance would contain a DOM tree equivalent of:

<section><h1 id="name"></h1>Email: <a id="link" href=""></a></section>

Then we can assign values to various parts we've just created as follows:

updateParts(parts, {name: "Ryosuke Niwa", email: "rniwa@webkit.org", "emailAddress": "mailto:rniwa@webkit.org"})

function updateParts(parts, object) {
    for (const name in object) {
        const part = parts[name];
        if (part) {
            part.value = object[name];
            part.commit();
        }
    }
}

which will update the DOM tree of instance to the equivalent of:

<section><h1 id="name">Ryosuke Niwa</h1>Email: <a id="link" href="mailto:rniwa@webkit.org">rniwa@webkit.org</a></section>

We can call updateParts many times over after this point, and the same DOM tree would get updated according to the values of the object passed to updateParts.

Partial Attribute Updates

Note in that above example, href attribute had initially contained mailto: before {email}but we could not capture this prefix in the attribute value becauseAttributePart` could only set the whole attribute value. One native solution for this limitation is to add the support for specifying a prefix or an offset within the attribute value like so:

emailAttributePart = new AttributePart(link, 'href', {'prefix': 'mailto:'})

or:

emailAttributePart = new AttributePart(link, 'href', 7)

However, this approach falls apart once when we have multiple parts within a single attribute since prefix or offset depends on the present value of other parts. e.g.

<section><h1 id="name" title="{lastName}, {firstName}">{name}</h1></section>

To facilitate cases like this, AttributeTemplatePart in the old proposal had a concept of attribute value setter, which takes the values of all parts involved for each content attribute and sets a single string value by concatenating them upon committing.

This approach worked in the old proposal because the browser engine was parsing a template element's mustache syntax and generated a group of AttributeTemplatePart together. The same approach can't be taken in the world we're introducing AttributePart as a primitive API.

Here, we consider a few alternatives. We can create a group of AttributePart together and implicitly relate them, create a specific AttributePartGroup object, or create a new class like PartialAttributePart which works together to set a single value using an AttributePart.

Option 1. Create Multiple AttributeParts Together

In this approach, AttributePart gets a new static function which creates a list of AttributeParts which work together to set a value when the values are to be committed:

const [firstName, lastName] = AttributePart.create(element, ‘title', null, [null, ' ', null]);
// Syntax to be improved. Here, a new AttributePart is created between each string.
  • Pros: Simplicity.
  • Cons: Coming up with a nice syntax to create a sequence of AttributePart and string can be tricky.

Option 2. Introduce AttributePartGroup

In this approach, we group multiple AttributeParts together by creating an explicit group:

const firstName = new AttributePart();
const lastName = new AttributePart();
const group = AttributePartGroup(element, ‘title');
group.append(firstName,  ', lastName);

This is morally equivalent to option 1 except there is an explicit grouping step.

  • Pros: Nicer syntax by the virtue of individual "partial" AttributePart's existence at the time of grouping. Code that assigns values to AttributePart only needs to know about AttributePart
  • Cons: More objects / complexity. AttributePart will have two modes.

Option 3. Introduce PartialAttributePart

Unlike option 2, this creates PartialAttributeParts from AttributePart, meaning that AttributePart in option 3 plays the role of AttributePartGroup in option 2:

const firstNamePartial = new PartialAttributePart();
const lastNamePartial = new PartialAttributePart();
const part = AttributePart(element, ‘title');
part.values = [firstNamePartial,  ', lastNamePartial];
  • Pros: Nicer syntax by the virtue of individual PartialAttributePart's existence at the time of grouping. AttributePart just knows one thing to do: to set the whole content attribute value.
  • Cons: More objects / complexity. Code that uses a template has to deal with two different kinds of objects: PartialAttributePart and AttributePart.

Partial Child Updates

Like partial attribute updates, when there are multiple points of interests under a single parent node, and they're next to each other, nextextSibling / previousSibling / nodeIndex does not adequately describe a specific location in the DOM when other parts insert or remove children. Again, the three options mentioned above apply as well as one more option:

Option 1. Create Multiple ChildNodeParts Together

In this approach, ChildNodePart gets a new static function which creates a list of ChildNodeParts which work together to set a value when the values are to be committed:

const [firstName, lastName] = ChildNodePart.create(element, null, null, [null, ' ', null]);
  • Pros: Simplicity.
  • Cons: Coming up with a nice syntax to create a sequence of ChildNodePart and string can be tricky.

Option 2. Introduce ChildNodePartGroup

In this approach, we group multiple ChildNodeParts together by creating an explicit group:

const firstName = new ChildNodePart();
const lastName = new ChildNodePart();
const group = ChildNodePartGroup(element, null, null);
group.append(firstName,  ', lastName);

This is morally equivalent to option 1 except there is an explicit grouping step.

  • Pros: Nicer syntax by the virtue of individual "partial" ChildNodeParts existence at the time of grouping. Code that sets new children to ChildNodeParts only needs to know about ChildNodePart.
  • Cons: More objects / complexity. ChildNodePart will have two modes.

Option 3. Introduce PartialNodeChildPart

This creates PartialNodeChildPart from ChildNodePart:

const firstNamePartial = new PartialNodeChildPart();
const lastNamePartial = new PartialNodeChildPart();
const part = NodeChildPart(element, null, null);
part.values = [firstNamePartial,  ', lastNamePartial];
  • Pros: Nicer syntax by the virtue of individual PartialChildNodeParts existence at the time of grouping.
  • Cons: More objects / complexity. Code that uses a template has to deal with two different kinds of objects: PartialChildNodePart and ChildNodePart.

Option 4. Allow nextSibling and previousSibling to point to another ChildNodePart

We would update ChildNodPart interface as follows and allow previousSibling and nextSibling to point to another ChildNodePart as well as Node:

interface ChildNodePart : Part {
    constructor(Node node, (Node or ChildNodePart)? previousSibling, (Node or ChildNodePart)? nextSibling);
    readonly attribute Node parentNode;
    readonly attribute (Node or ChildNodePart)? previousSibling;
    readonly attribute (Node or ChildNodePart)? nextSibling;
};

Then we can insert two consecutive NodeChildParts by relating them in the constructor as follows:

const firstName = new NodeChildPart(element);
const lastName = new NodeChildPart(element, firstName);
element.append(firstName, lastName); // For illustration purposes, there is no space between the two parts.

Note that lastName takes firstName as the previous sibling but firstName doesn't lastName as the next sibling (since lastName doesn't exist at that point in time). This would mean that we'd have to do a bit of implicit updating of previous/next sibling of other parts in the constructor.

An alternative is to add an explicit API to chain multiple parts togethers:

const firstName = new NodeChildPart(element);
const lastName = new NodeChildPart(element);
ChildNodePart.chain(firstName, lastName);
element.append(firstName, lastName); // For illustration purposes, there is no space between the two parts.
  • Pros: API simplicity. Only NodeChildPart is involved but construction isn't as awkward as option 1.
  • Cons: "Chained" NodeChildPart must coordinate when applying mutations.

Updating JS Property / Invoking Setter

Adding the support to set a JavaScript property on, or invoking the setter of, a DOM node poses an unique challenge but it has been one of the most consistent feedback. A naive approach can reduce or eliminate the benefit of batching multiple DOM mutations together since invoking JavaScript forces the browser engine to update all its internal states to a consistent state prior to any script execution. On the other hand, re-ordering so that all JavaScript related operations happen at the end can cause a JavaScript setter to override content attributes which appear later in the markup, which would be a confusing behavior for users of a template engine.

This proposal attempts to solve this issue by delaying JavaScript execution until all DOM mutations are done but interleaving custom element callback reactions in-between setter calls. This ensures custom element callback reaction for attribute changes and setter calls occur in the order they are scheduled.

To achieve this observable behavior, when a DOM part of this type, let us call it PropertyPart, appears on an element, we insert a new kind of an item into the element's custom element reaction queue. This new kind of item, let us call it property reaction, is simply an arbitrary entry point back to the code which invokes the JavaScript setter unlike other items in the queue which involves some predefined method passed to customElements.define.

Consider two AttributeParts and PropertyPart which all work an element A, and another set of AttributePart and PropertyPart for another element B, and a sequence of updates as shown below where each A* and B* are AttributeParts and PropertyParts for respective elements:

BProp.value = ‘foo';
AAttr1.value = ‘foo';
BAttr.value = ‘foo';
AProp.value = ‘foo';
AAttr2.value = ‘foo';

Recall that these assignments to DOM parts simply stage values to be set when the DOM part groups later commit these changes. It has a queue unique to each element of the "staged" DOM parts with pending mutations. In this case: [AAttr1, AProp, AAttr2] for A and [BProp, BAttr] for B. Suppose A appears before B in the DOM part group all these DOM parts belong. Then as the browser engine makes DOM mutations for AAttr1 and AAttr2 on A, it also schedules a property reaction for AProp between the two. It would then schedule a property reaction for BProp then makes DOM mutation for BAttr.

If A is a custom element, then the following sequence of events will take place:

  • attributeChangedCallback for AAttr1 gets invoked.
  • Property reaction and therefore the corresponding JavaScript setter for AProp gets invoked.
  • attributeChangedCallback for AAttr1 gets invoked.

The property reaction of PropertyPart simply invokes Set(O, P, V, Throw) on A. With this in mind, PropertyPart's interface could simply be:

interface PropertyPart : Part {
    constructor(Node node, DOMString propertyName);
    readonly attribute propertyName;
}

Since the mechanism of invoking a property reaction is generic enough to execute arbitrary scripts, not just Set(O, P, V, Throw), we could generalize it to support arbitrary code execution like this:

callback CustomPartCallback = undefined (Node node, CustomPart part);
interface CustomPart : Part {
    constructor(Node node, CustomPartCallback callback);
}

Batching: Template or Not

In the old proposal, the template element was a natural aggregator of all the pending mutations and batch them together in a lump sum. TemplatePart being tied to a template element has been one of the most persistent complaints by web developers as well, and there have been multiple alternate proposals in this arena.

We would like for Part-only API to still provide the ability to batch multiple mutations for the added performance gain. This will also avoid having to create two versions of API: one for declarative custom elements using a template with the performance benefit, and another separate per-Part mutation API.

One alternative is to make the concept of DOM part group a real DOM interface:

interface PartGroup {
    constructor(sequence<Part> parts);
    readonly attribute FrozenArray<Part> parts;
    void commit();
}

Note that if we allow a single Part to belong to multiple PartGroups, the first PartGroup which commits the changes would apply the mutations. In effect, this allows non-partitioned grouping of Part objects to be committed together.

There is also a question of how mutable parts should be, and whether a PartGroup can appear as a part of another PartGroup for nested template instances or not. It doesn't make much sense for the list of DOM parts associated with PartGroup to get mutated after we've started committing things but there certainly is a room for adding or removing DOM parts based on new input or state.

If we made the relationship between DOM parts and PartGroup not dynamically mutable, users of this API could still create a new PartGroup each time such a mutation would have needed instead.

Batching Order

What order should the DOM part group commit each DOM parts' new values? In the old proposal, the tree order at the time of parsing the template element's content was an obvious choice.

This isn't possible in Part-only API because there isn't a single point in time when all DOM parts are created. We could re-evaluate each time a new DOM part is added to a DOM part group but that would incur a high runtime cost since it would likely require traversing all node trees referenced by each DOM part. Dynamically evaluating at the time of committing pending changes doesn't work either because some of the nodes might not be a part of the same node tree until some of the pending changes are committed.

The simplest choice is probably the order in which DOM part was inserted to a given DOM part group. Although there could be multiple DOM parts which reference the same element in different parts of the array, that doesn't necessarily pose an obvious issue other than a slight inefficiency in batching certain DOM operations. Because the template engine is the one which creates these Part objects, this is also probably not an issue in practice. In any case, we can batch mutations per element like we do with custom element reaction queue if necessary.

Cloning Parts

Similarly, in the old proposal, the template element was a natural source of template parts. The browser engine can cache the result of parsing the template content and use that to efficiently generate template parts.

To achieve a similar efficiency with the Part-only API, we need a way to clone a Node subtree with Parts associated with a given PartGroup:

partial interface Node {
    NodeWithParts cloneTree(optional CloneOptions options = {});
};

dictionary CloneOptions {
    boolean deep = true;
    Document? document;
    PartGroup? partGroup;
};

dictionary NodeWithParts {
    Node node;
    PartGroup? partGroup;
};

Here, we're proposing a slightly nicer API by combining importNode and cloneNode and making the cloning deep by default.

There are some questions here as well. What happens to the current values of DOM parts? Do we allow DOM parts to have some non-initial values and do we clone those values as well? If so, what do we do with PropertyPart / CustomPart?