Skip to content
This repository has been archived by the owner on Feb 12, 2024. It is now read-only.

Convert to custom elements spec v1 #30

Open
wants to merge 15 commits into
base: master
Choose a base branch
from
46 changes: 21 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,20 @@ You can take the same ideas (and standards), apply them directly server side, to
```javascript
var components = require("server-components");

// Get the prototype for a new element
var NewElement = components.newElement();

// When the element is created during DOM parsing, you can transform the HTML inside it.
// This can be configurable too, either by setting attributes or adding HTML content
// inside it or elsewhere in the page it can interact with. Elements can fire events
// that other elements can receive to allow interactions, or even expose methods
// or data that other elements in the page can access directly.
NewElement.createdCallback = function () {
this.innerHTML = "Hi there";
};
// Define a new class that extends a native HTML Element
class NewElement extends components.HTMLElement {
// When the element is created during DOM parsing, you can transform the HTML inside it.
// This can be configurable too, either by setting attributes or adding HTML content
// inside it or elsewhere in the page it can interact with. Elements can fire events
// that other elements can receive to allow interactions, or even expose methods
// or data that other elements in the page can access directly.
connectedCallback() {
this.innerHTML = "Hi there";
}
}

// Register the element with an element name
components.registerElement("my-new-element", { prototype: NewElement });
components.define("my-new-element", NewElement);
```

For examples of more complex component definitions, take a look at the [example components](https://github.com/pimterry/server-components/blob/master/component-examples.md)
Expand Down Expand Up @@ -83,15 +83,15 @@ There aren't many published sharable components to drop in quite yet, as it's st

### Top-level API

#### `components.newElement()`
#### `components.HTMLElement`

Creates a returns a new custom HTML element prototype, extending the HTMLElement prototype.

Note that this does *not* register the element. To do that, call `components.registerElement` with an element name, and options (typically including the prototype returned here as your 'prototype' value).

This is broadly equivalent to `Object.create(HTMLElement.prototype)` in browser land, and exactly equivalent here to `Object.create(components.dom.HTMLElement.prototype)`. You can call that yourself instead if you like, but it's a bit of a mouthful.

#### `components.registerElement(componentName, options)`
#### `components.define(componentName, Constructor)`

Registers an element, so that it will be used when the given element name is found during parsing.

Expand Down Expand Up @@ -131,31 +131,27 @@ These methods are methods you can implement on your component prototype (as retu

Any methods that are implemented, from this selection or otherwise, will be exposed on your element in the DOM during rendering. I.e. you can call `document.querySelector("my-element").setTitle("New Title")` and to call the `setTitle` method on your object, which can then potentially change how your component is rendered.

#### `yourComponent.createdCallback(document)`
#### `yourComponentConstructor.prototype.connectedCallback(document)`

Called when an element is created.
Called when an element is attached to the DOM.

**This is where you put your magic!** Rewrite the elements contents to dynamically generate what your users will actually see client side. Read configuration from attributes or the initial child nodes to create flexible reconfigurable reusable elements. Register for events to create elements that interact with the rest of the application structure. Build your page.

This method is called with `this` bound to the element that's being rendered (just like in browser-land). The `document` object that would normally be available as a global in the browser is instead passed as an argument here for convenience (useful if you want to use `document.querySelectorAll` and friends). Note that if you're rendering with `renderFragment` instead of `renderPage` this will be a DocumentFragment, not a Document, although in almost all cases you can safely ignore this.

If this callback returns a promise, the rendering process will not resolve until that promise does, and will fail if that promise fails. You can use this to perform asynchronous actions without your component definitions. Pull tweets from twitter and draw them into the page, or anything else you can imagine.

These callbacks are called in opening tag order, so a parent's createdCallback is called, then each of its children's, then its next sibling element.
These callbacks are called in opening tag order, so a parent's connectedCallback is called, then each of its children's, then its next sibling element.

#### `yourComponent.attachedCallback(document)`

Called when the element is attached to the DOM. This is different to when it's created when your component is being built programmatically, not through HTML parsing. *Not yet implemented*

#### `yourComponent.detachedCallback(document)`
#### `yourComponentConstructor.prototype.disconnectedCallback(document)`

Called when the element is removed from the DOM. *Not yet implemented*

#### `yourComponent.attributeChangedCallback(document)`
#### `yourComponentConstructor.prototype.attributeChangedCallback(document)`

Called when an attribute of the element is added, changed, or removed. *Not yet implemented*.
Called when an attribute of the element is added, changed, or removed. *Partially implemented;* runs on component initialization.

**So far only the createdCallback is implemented here, as the others are less relevant initially for the key simpler cases. Each of those will be coming in time though! Watch this space.**
**So far only the connectedCallback is implemented here, as the others are less relevant initially for the key simpler cases. Each of those will be coming in time though! Watch this space.**

## Why does this exist?

Expand Down
49 changes: 24 additions & 25 deletions component-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,14 @@ With the web component below, rendering `<my-greeting></my-greeting>` will resul
`<my-greeting>Hi there</my-greeting>`.

```javascript
var components = require("server-components");

var StaticElement = components.newElement();
StaticElement.createdCallback = function () {
this.innerHTML = "Hi there";
};

components.registerElement("my-greeting", { prototype: StaticElement });
var customElements = require("server-components");

class StaticElement extends customElements.HTMLElement {
connectedCallback() {
this.innerHTML = "Hi there"
}
}
customElements.define("my-greeting", StaticElement);
```

This is very basic, and toy cases like this aren't immediately useful, but this can be helpful for standard
Expand All @@ -40,17 +40,17 @@ example is below: a visitor counter. All the rage in the 90s, with web component
comeback!

```javascript
var components = require("server-components");
var customElements = require("server-components");

var CounterElement = components.newElement();
var currentCount = 0;

CounterElement.createdCallback = function () {
currentCount += 1;
this.innerHTML = "There have been " + currentCount + " visitors.";
};

components.registerElement("visitor-counter", { prototype: CounterElement });
class CounterElement extends customElements.HTMLElement {
connectedCallback() {
currentCount += 1;
this.innerHTML = "There have been " + currentCount + " visitors.";
}
}
customElements.define("visitor-counter", CounterElement);
```

After a few visitors, this will render `<visitor-counter></visitor-counter>` into something like
Expand Down Expand Up @@ -81,17 +81,16 @@ Components can be parameterized in all sorts of ways. One interesting pattern is
For example, you might want a component that wraps HTML, parses all the text within, and replaces URL strings with actual links (using the excellent [Linkify library](https://github.com/SoapBox/linkifyjs), but here in a server side DOM, not a real one):

```javascript
var components = require("server-components");
var customElements = require("server-components");
var linkify = require("linkifyjs/element");

var LinkifyElement = components.newElement();

LinkifyElement.createdCallback = function (document) {
// Delegate the whole thing to a real normal front-end library!
linkify(this, { target: () => null, linkClass: "autolinked" }, document);
};

components.registerElement("linkify-urls", { prototype: LinkifyElement });
class LinkifyElement extends customElements.HTMLElement {
connectedCallback() {
// Delegate the whole thing to a real front-end library!
linkify(this, { target: () => null, linkClass: "autolinked" }, document);
}
}
customElements.define("linkify-urls", LinkifyElement);
```

With this, we can pass HTML into Server Components that looks like
Expand Down
16 changes: 13 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,22 @@
"watch": "^0.18.0"
},
"dependencies": {
"domino": "^1.0.23",
"validate-element-name": "^1.0.0"
"domino": "^1.0.23"
},
"jshintConfig": {
"esversion": 6,
"node": true
"node": true,
"globals": {
"describe": false,
"xdescribe": false,
"it": false,
"xit": false,
"before": false,
"beforeEach": false,
"after": false,
"afterEach": false,
"expect": false
}
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting. This makes perfect sense, and I'm not sure why been it's passing without it all this time! Any idea? Right now, it seems to pass fine on my machine and in CI without this.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding "use strict" to the top of the test files caused the linter to start complaining.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, that'll do it. 👍

},
"engines": {
"node": ">= 4.0.0"
Expand Down
44 changes: 44 additions & 0 deletions src/extend-domino.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//
// Strict mode disallows us to overwrite Document.prototype properties.
// This file is to stay out of strict mode.
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These strict mode errors are happening because these properties have been defined with writable set to false somewhere. Strict mode doesn't aim to change working runtime behaviour - it just exposes issues that are otherwise hidden. Those errors are appearing here because these writes don't actually do anything - they're silently failing. You're not successfully changing createElement here.

I'm not totally clear on the goal of this code, but I've had a quick test, and if you remove 'createElement' and 'createElementNS' below here then you can enable strict mode on this file, and all the tests still pass. That suggests either there's a bunch more code involved here (like _createElement) that we could delete too, or that we're missing tests that cover whatever this is doing.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. My understanding is that the code is supposed to support document.createElement for custom elements. An example use case is a custom element creating another custom element, and adding it to a part of the page that has already been traversed.

//
var domino = require("domino");
var Document = require('domino/lib/Document');
var Element = require('domino/lib/Element');


module.exports = function (newHTMLElement, _createElement) {
var result = {};

//
// Patch document.createElement
//
Document.prototype.createElement = function(tagName, options) {
return _createElement(this, tagName, options, true);
};

//
// Patch HTMLElement
//
result.HTMLElement = newHTMLElement;
result.HTMLElement.prototype = Object.create(domino.impl.HTMLElement.prototype, {
constructor: {value: result.HTMLElement, configurable: true, writable: true},
});


//
// Patch doc.createElementNS
//
var HTMLNS = 'http://www.w3.org/1999/xhtml';
var _origCreateElementNS = Document.prototype.createElementNS;

Document.prototype.createElementNS = function(namespaceURI, qualifiedName) {
if (namespaceURI === 'http://www.w3.org/1999/xhtml') {
return this.createElement(qualifiedName);
} else {
return _origCreateElementNS.call(this, namespaceURI, qualifiedName);
}
};

return result;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're keeping this, it needs some comments. What are these patches doing to Domino's built-in behaviour? Why doesn't Domino's DOM + the polyfill do what we want already?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was just following what the original polyfill did. I believe this is supposed to support programatically creating custom elements.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Interesting, ok. We should add tests for that then. If this code is necessary, that's probably broken, because this code doesn't work.

The right answer to this might well be that these properties are writable in a browser, but not in Domino. That's probably not supposed to be that case, so we should talk to Domino, make this writable there, and then everything'll be fine. Can you check that that's the problem? If so, I'm happy to look at sorting this in Domino.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with you, that is the right approach to take. The browser does in fact allow you to override Document.prototype.createElement and Document.prototype.createElementNS.

};
119 changes: 76 additions & 43 deletions src/index.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"use strict";

var domino = require("domino");
var validateElementName = require("validate-element-name");

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just spotted this - we should remove the dependency if we're not using this any more.

/**
* The DOM object (components.dom) exposes tradition DOM objects (normally globally available
Expand All @@ -17,41 +16,39 @@ exports.dom = domino.impl;
* with an element name, and options (typically including the prototype returned here as your
* 'prototype' value).
*/
exports.newElement = function newElement() {
return Object.create(domino.impl.HTMLElement.prototype);
};
var CustomElementRegistry = require('./registry');
exports.customElements = CustomElementRegistry.instance();
exports.HTMLElement = CustomElementRegistry.HTMLElement;

var registeredElements = {};

/**
* Registers an element, so that it will be used when the given element name is found during parsing.
*
* Element names are required to contain a hyphen (to disambiguate them from existing element names),
* be entirely lower-case, and not start with a hyphen.
*
* The only option currently supported is 'prototype', which sets the prototype of the given element.
* This prototype will have its various callbacks called when it is found during document parsing,
* and properties of the prototype will be exposed within the DOM to other elements there in turn.
* Re-export methods for convenience
*/
exports.registerElement = function registerElement(name, options) {
var nameValidationResult = validateElementName(name);
if (!nameValidationResult.isValid) {
throw new Error(`Registration failed for '${name}'. ${nameValidationResult.message}`);
}
exports.define = function (name, constructor, options) {
return CustomElementRegistry.instance().define(name, constructor, options);
};
exports.get = function (name) {
return CustomElementRegistry.instance().get(name);
};
exports.whenDefined = function (name) {
return CustomElementRegistry.instance().whenDefined(name);
};
exports.reset = function (name) {
return CustomElementRegistry.instance().reset();
};

if (options && options.prototype) {
registeredElements[name] = options.prototype;
} else {
registeredElements[name] = exports.newElement();
}

return registeredElements[name].constructor;
};
const _upgradedProp = '__$CE_upgraded';


function transformTree(document, visitedNodes, currentNode, callback) {

var task = visitedNodes.has(currentNode) ? undefined : callback(currentNode);

visitedNodes.add(currentNode);
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is visitedNodes here? It's only useful if we've got loops in the tree, but this is a DOM, so afaik there's no way we can have loops. Is there a case where this is necessary?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was in the original polyfill. I believe it's possible if a custom element decides to move itself around within the DOM.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok. Yes, that makes perfect sense.


function recurseTree(rootNode, callback) {
for (let node of rootNode.childNodes) {
callback(node);
recurseTree(node, callback);
for (var child of currentNode.childNodes) {
transformTree(document, visitedNodes, child, callback);
}
}

Expand Down Expand Up @@ -89,24 +86,28 @@ function renderNode(rootNode) {
let createdPromises = [];

var document = getDocument(rootNode);
var visitedNodes = new Set();
var customElements = exports.customElements;

recurseTree(rootNode, (foundNode) => {
if (foundNode.tagName) {
let nodeType = foundNode.tagName.toLowerCase();
let customElement = registeredElements[nodeType];
if (customElement) {
// TODO: Should probably clone node, not change prototype, for performance
Object.setPrototypeOf(foundNode, customElement);
if (customElement.createdCallback) {
createdPromises.push(new Promise((resolve) => {
resolve(customElement.createdCallback.call(foundNode, document));
}));
}
transformTree(document, visitedNodes, rootNode, function render (element) {

const definition = customElements.getDefinition(element.localName);

if (definition) {
if ( element[_upgradedProp] ) {
return;
}
upgradeElement(element, definition, true);

if (definition.connectedCallback) {
var p = new Promise(function(resolve, reject) {
resolve( definition.connectedCallback.call(element, document) );
});
createdPromises.push(p);
}
}
});

return Promise.all(createdPromises).then(() => rootNode);
return Promise.all(createdPromises).then(function(){ return rootNode; });
}

/**
Expand Down Expand Up @@ -154,3 +155,35 @@ function getDocument(rootNode) {
return rootNode;
}
}

function upgradeElement (element, definition, callConstructor) {
const prototype = definition.constructor.prototype;
Object.setPrototypeOf(element, prototype);
if (callConstructor) {
CustomElementRegistry.instance()._setNewInstance(element);
new (definition.constructor)();
element[_upgradedProp] = true;
}

const observedAttributes = definition.observedAttributes;
const attributeChangedCallback = definition.attributeChangedCallback;
if (attributeChangedCallback && observedAttributes.length > 0) {

// Trigger attributeChangedCallback for existing attributes.
// https://html.spec.whatwg.org/multipage/scripting.html#upgrades
for (let i = 0; i < observedAttributes.length; i++) {
const name = observedAttributes[i];
if (element.hasAttribute(name)) {
const value = element.getAttribute(name);
attributeChangedCallback.call(element, name, null, value, null);
}
}
}
}

//
// Helpers
//
function map (arrayLike, fn) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's neater to just convert the array-like into a real array, and then use real map, rather than reimplementing map and any other functions we need all from scratch. function asArray(arrayLike) { return [].slice.apply(arrayLike) }

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's neater, but also creates an two extra arrays (an empty one and a copy for the actual mapping).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can do Array.prototype.slice instead.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alright, updated.

return Array.prototype.slice.call(arrayLike).map(fn);
}
Loading