Skip to content
This repository was 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
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 21 additions & 25 deletions README.md
Original file line number Diff line number Diff line change
@@ -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)
@@ -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.

@@ -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?

49 changes: 24 additions & 25 deletions component-examples.md
Original file line number Diff line number Diff line change
@@ -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
@@ -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
@@ -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
16 changes: 13 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -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"
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
@@ -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);
}
}

@@ -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; });
}

/**
@@ -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);
}
296 changes: 296 additions & 0 deletions src/registry.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,296 @@
"use strict";
Copy link
Owner

Choose a reason for hiding this comment

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

How much of this is the original real polyfill, and how much is extensions on top of it?

If at all possible (and I do get that it might not be) I'd like to keep them separate. It's going to be way easier to maintain this if there's a polyfill file that we can trust (and upgrade) independently, and then a separate bunch of code making the any tweaks required to hook it into Domino, and/or wrapping the polyfill to change the interface.

Copy link
Author

Choose a reason for hiding this comment

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

getDefinition and undefine are extensions. The rest of the changes were to make it compatible with domino instead of the actual DOM.

Copy link
Owner

Choose a reason for hiding this comment

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

It'd be useful to keep all those changes separate from the core polyfill though, rather than mixing them all in together. If we can. If you've got examples where we can't possibly make the changes separately then that's ok too, but we should document those, so we can work out what's going on here in future.

Copy link
Author

Choose a reason for hiding this comment

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

But this sort of polyfill modifies the DOM environment. Normally it would modify window and window.document, but since there is no such globals, it modifies domino instead. In other words, modifying domino is a primary concern of the polyfill.

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 fine that it modifies the DOM, my concern is that we're modifying the polyfill from within it, rather than externally, which makes this codebase harder to manage.

What I'm aiming to separate are the modifications to the DOM that any custom elements polyfill would do for any DOM, from both the modifications to Domino we make to get the polyfill working and the modifications to the polyfill we make to provide this library's API.

Some practical reasons:

  • I'd like to be able to replace the polyfill file in future, to upgrade it without having to worry about recreating changes we've made within it.
  • At some point hopefully Domino will get built-in custom elements support - I'll need to be able to remove this polyfill completely, and easily see what the extra parts we've built on top are (i.e. getDefinition) so they can be ported to build on top of Domino's implementation instead.
  • It makes it much easier to review and manage. I shouldn't try and review the polyfill code here now - I should review extensions we're making on top. In future this remains true: if there's a bug in the polyfill, it should be fixed upstream, but if there's a bug in our polyfill wrapper, it should be fixed it here.

It's easier to do all that if we can keep the two as separate as possible.

Copy link
Author

Choose a reason for hiding this comment

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

The changes to the original polyfill were things like this:

const origHTMLElement = win.HTMLElement;
// TO
const origHTMLElement = domino.impl.HTMLElement;

which were contained in the same file. Are you suggesting we move this to a separate file? Sorry if I'm still misunderstanding.

Copy link
Owner

Choose a reason for hiding this comment

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

Focusing on the use cases is the key part of this I think. Given the change you have there, if I wanted to pull in a new version of the polyfill, I'd have to recreate that change, and right now I'd have to do that totally by hand from scratch. That'll be painful, and I'd like to avoid this.

At the very least, some documentation of exactly what we've changed (even perhaps just as git history) would be a start, but separating our changes from the polyfill source entirely is probably doable, I hope. For example, there's other things we can do to wrap a library and inject our own window into it, so you don't need to change the lines as you have there. See https://github.com/pimterry/leaflet-map-server-component/blob/master/src/leaflet-for-server.js, which injects window, document and navigator globals into leaflet, to wrap it without changing the core leaflet code.

This might be a bit fiddly. Have a go, but if you really don't want to dig into this, feel free to commit an unchanged version of the polyfill followed by your changed version, and I'll extract the diff out as much as possible. That also conveniently lets me review the changes we're making to the polyfill, which is an important part of this too.

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

const _upgradedProp = '__$CE_upgraded';

const _customElements = () => CustomElementRegistry.instance();

/**
* A registry of custom element definitions.
*
* See https://html.spec.whatwg.org/multipage/scripting.html#customelementsregistry
*
* Implementation based on https://github.com/webcomponents/custom-elements/blob/master/src/custom-elements.js
*
*/
var _instance = null;
class CustomElementRegistry {

static instance () {
if ( ! _instance ) _instance = new CustomElementRegistry();
return _instance;
}

constructor() {
this._definitions = new Map();
this._constructors = new Map();
this._whenDefinedMap = new Map();

this._newInstance = null;
}

// HTML spec part 4.13.4
// https://html.spec.whatwg.org/multipage/scripting.html#dom-customelementsregistry-define
/**
* @param {string} name
* @param {function(new:HTMLElement)} constructor
* @param {{extends: string}} options
* @return {undefined}
*/
define(name, constructor, options) {
// 1:
if (typeof constructor !== 'function') {
throw new TypeError('constructor must be a Constructor');
}

// 2. If constructor is an interface object whose corresponding interface
// either is HTMLElement or has HTMLElement in its set of inherited
// interfaces, throw a TypeError and abort these steps.
//
// It doesn't appear possible to check this condition from script

// 3:
const nameError = checkValidCustomElementName(name);
if (nameError) throw nameError;

// 4, 5:
// Note: we don't track being-defined names and constructors because
// define() isn't normally reentrant. The only time user code can run
// during define() is when getting callbacks off the prototype, which
// would be highly-unusual. We can make define() reentrant-safe if needed.
if (this._definitions.has(name)) {
throw new Error(`An element with name '${name}' is already defined`);
}

// 6, 7:
if (this._constructors.has(constructor)) {
throw new Error(`Definition failed for '${name}': ` +
`The constructor is already used.`);
}

// 8:
/** @type {string} */
const localName = name;

// 9, 10: We do not support extends currently.

// 11, 12, 13: Our define() isn't rentrant-safe

// 14.1:
const prototype = constructor.prototype;

// 14.2:
if (typeof prototype !== 'object') {
throw new TypeError(`Definition failed for '${name}': ` +
`constructor.prototype must be an object`);
}

function getCallback(callbackName) {
const callback = prototype[callbackName];
if (callback !== undefined && typeof callback !== 'function') {
throw new Error(`${localName} '${callbackName}' is not a Function`);
}
return callback;
}

// 3, 4:
const connectedCallback = getCallback('connectedCallback');

// 5, 6:
const disconnectedCallback = getCallback('disconnectedCallback');

// Divergence from spec: we always throw if attributeChangedCallback is
// not a function.

// 7, 9.1:
const attributeChangedCallback = getCallback('attributeChangedCallback');

// 8, 9.2, 9.3:
const observedAttributes =
(attributeChangedCallback && constructor.observedAttributes) || [];

// 15:
/** @type {CustomElementDefinition} */
const definition = {
name: name,
localName: localName,
constructor: constructor,
connectedCallback: connectedCallback,
disconnectedCallback: disconnectedCallback,
attributeChangedCallback: attributeChangedCallback,
observedAttributes: observedAttributes,
};

// 16:
this._definitions.set(localName, definition);
this._constructors.set(constructor, localName);

// 17, 18, 19:
// Since we are rendering server-side, no need to upgrade doc;
// custom elements will be defined before rendering takes place.
// this._upgradeDoc();

// 20:
const deferred = this._whenDefinedMap.get(localName);
if (deferred) {
deferred.resolve(undefined);
this._whenDefinedMap.delete(localName);
}
}

/**
* Returns the constructor defined for `name`, or `null`.
*
* @param {string} name
* @return {Function|undefined}
*/
get(name) {
// https://html.spec.whatwg.org/multipage/scripting.html#custom-elements-api
const def = this._definitions.get(name);
return def ? def.constructor : undefined;
}

/**
* Returns a `Promise` that resolves when a custom element for `name` has
* been defined.
*
* @param {string} name
* @return {!Promise}
*/
whenDefined(name) {
// https://html.spec.whatwg.org/multipage/scripting.html#dom-customelementsregistry-whendefined
const nameError = checkValidCustomElementName(name);
if (nameError) return Promise.reject(nameError);
if (this._definitions.has(name)) return Promise.resolve();

let deferred = this._whenDefinedMap.get(name);
if (deferred) return deferred.promise;

let resolve;
const promise = new Promise(function(_resolve, _) {
resolve = _resolve;
});
deferred = {promise, resolve};
this._whenDefinedMap.set(name, deferred);
return promise;
}

/**
* @param {?HTMLElement} instance
* @private
*/
_setNewInstance(instance) {
this._newInstance = instance;
}

/**
* WARNING: NOT PART OF THE SPEC
*
* @param {string} localName
* @return {?CustomElementDefinition}
*/
getDefinition(localName) {
return this._definitions.get(localName);
}

/**
* WARNING: NOT PART OF THE SPEC
*
* @param {string} localName
* @return {undefined}
*/
reset() {
this._definitions.clear();
this._constructors.clear();
this._whenDefinedMap.clear();
}
}
exports = module.exports = CustomElementRegistry;


//
// - Overwrite domino's new element constructor
// - Patch domino's document.createElement
//
const origHTMLElement = domino.impl.HTMLElement;
const _origCreateElement = Document.prototype.createElement;

const newHTMLElement = function HTMLElement() {
const customElements = _customElements();

// If there's an being upgraded, return that
if (customElements._newInstance) {
const i = customElements._newInstance;
customElements._newInstance = null;
return i;
}
if (this.constructor) {
// Find the tagname of the constructor and create a new element with it
const tagName = customElements._constructors.get(this.constructor);
// Domino does not need a doc as a `this` parameter
return _createElement(null, tagName, undefined, false);
}
throw new Error('Unknown constructor. Did you call customElements.define()?');
};

/**
* Creates a new element and upgrades it if it's a custom element.
* @param {!Document} doc
* @param {!string} tagName
* @param {Object|undefined} options
* @param {boolean} callConstructor whether or not to call the elements
* constructor after upgrading. If an element is created by calling its
* constructor, then `callConstructor` should be false to prevent double
* initialization.
*/
function _createElement(doc, tagName, options, callConstructor) {
const customElements = _customElements();
const element = options ? _origCreateElement.call(doc, tagName, options) :
_origCreateElement.call(doc, tagName);
const definition = customElements._definitions.get(tagName.toLowerCase());
if (definition) {
customElements._upgradeElement(element, definition, callConstructor);
}
return element;
}


var patched = require('./extend-domino')(newHTMLElement, _createElement);
exports.HTMLElement = patched.HTMLElement;


/**
* 2.3
* http://w3c.github.io/webcomponents/spec/custom/#dfn-element-definition
* @typedef {{
* name: string,
* localName: string,
* constructor: function(new:HTMLElement),
* connectedCallback: (Function|undefined),
* disconnectedCallback: (Function|undefined),
* attributeChangedCallback: (Function|undefined),
* observedAttributes: Array<string>,
* }}
*/
let CustomElementDefinition;


const reservedTagList = [
'annotation-xml',
'color-profile',
'font-face',
'font-face-src',
'font-face-uri',
'font-face-format',
'font-face-name',
'missing-glyph',
];

function checkValidCustomElementName(name) {
if (!(/^[a-z][.0-9_a-z]*-[\-.0-9_a-z]*$/.test(name) &&
reservedTagList.indexOf(name) === -1)) {
return new Error(`The element name '${name}' is not valid.`);
}
}
44 changes: 28 additions & 16 deletions test/asynchrony-test.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,38 @@
"use strict";
var expect = require('chai').expect;

var components = require("../src/index.js");

describe("An asynchronous element", () => {
beforeEach(() => {
components.reset();
});

it("blocks rendering until they complete", () => {
var SlowElement = components.newElement();
SlowElement.createdCallback = function () {
return new Promise((resolve, reject) => {
setTimeout(() => {
this.textContent = "loaded!";
resolve();
}, 1);
});
};
components.registerElement("slow-element", { prototype: SlowElement });
class SlowElement extends components.HTMLElement {
connectedCallback() {
return new Promise((resolve, reject) => {
setTimeout(() => {
this.textContent = "loaded!";
resolve();
}, 1);
});
}
}
components.define("slow-element", SlowElement);

return components.renderFragment("<slow-element></slow-element>").then((output) => {
expect(output).to.equal("<slow-element>loaded!</slow-element>");
});
});

it("throw an async error if a component fails to render synchronously", () => {
var FailingElement = components.newElement();
FailingElement.createdCallback = () => { throw new Error(); };
components.registerElement("failing-element", { prototype: FailingElement });
class FailingElement extends components.HTMLElement {
connectedCallback() {
throw new Error();
}
}
components.define("failing-element", FailingElement);

return components.renderFragment(
"<failing-element></failing-element>"
@@ -33,9 +42,12 @@ describe("An asynchronous element", () => {
});

it("throw an async error if a component fails to render asynchronously", () => {
var FailingElement = components.newElement();
FailingElement.createdCallback = () => Promise.reject(new Error());
components.registerElement("failing-element", { prototype: FailingElement });
class FailingElement extends components.HTMLElement {
connectedCallback() {
return Promise.reject(new Error());
}
}
components.define("failing-element", FailingElement);

return components.renderFragment(
"<failing-element></failing-element>"
51 changes: 28 additions & 23 deletions test/basics-test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
"use strict";
var expect = require('chai').expect;

var components = require("../src/index.js");
@@ -12,23 +13,25 @@ describe("Basic component functionality", () => {
});

it("replaces components with their rendered result", () => {
var NewElement = components.newElement();
NewElement.createdCallback = function () { this.textContent = "hi there"; };
components.registerElement("my-element", { prototype: NewElement });
class NewElement extends components.HTMLElement {
connectedCallback() {
this.textContent = "hi there";
}
}
components.define("my-element", NewElement);

return components.renderFragment("<my-element></my-element>").then((output) => {
expect(output).to.equal("<my-element>hi there</my-element>");
});
});

it("can wrap existing content", () => {
var PrefixedElement = components.newElement();
PrefixedElement.createdCallback = function () {
this.innerHTML = "prefix:" + this.innerHTML;
};
components.registerElement("prefixed-element", {
prototype: PrefixedElement
});
class PrefixedElement extends components.HTMLElement {
connectedCallback() {
this.innerHTML = "prefix:" + this.innerHTML;
}
}
components.define("prefixed-element", PrefixedElement);

return components.renderFragment(
"<prefixed-element>existing-content</prefixed-element>"
@@ -38,12 +41,13 @@ describe("Basic component functionality", () => {
});

it("allows attribute access", () => {
var BadgeElement = components.newElement();
BadgeElement.createdCallback = function () {
var name = this.getAttribute("name");
this.innerHTML = "My name is: <div class='name'>" + name + "</div>";
};
components.registerElement("name-badge", { prototype: BadgeElement });
class BadgeElement extends components.HTMLElement {
connectedCallback() {
var name = this.getAttribute("name");
this.innerHTML = "My name is: <div class='name'>" + name + "</div>";
}
}
components.define("name-badge", BadgeElement);

return components.renderFragment(
'<name-badge name="Tim Perry"></name-badge>'
@@ -53,13 +57,14 @@ describe("Basic component functionality", () => {
});

it("can use normal document methods like QuerySelector", () => {
var SelfFindingElement = components.newElement();
SelfFindingElement.createdCallback = function (document) {
var hopefullyThis = document.querySelector("self-finding-element");
if (hopefullyThis === this) this.innerHTML = "Found!";
else this.innerHTML = "Not found, found " + hopefullyThis;
};
components.registerElement("self-finding-element", { prototype: SelfFindingElement });
class SelfFindingElement extends components.HTMLElement {
connectedCallback(document) {
var hopefullyThis = document.querySelector("self-finding-element");
if (hopefullyThis === this) this.innerHTML = "Found!";
else this.innerHTML = "Not found, found " + hopefullyThis;
}
}
components.define("self-finding-element", SelfFindingElement);

return components.renderFragment(
'<self-finding-element></self-finding-element>'
31 changes: 13 additions & 18 deletions test/element-validation-test.js
Original file line number Diff line number Diff line change
@@ -1,47 +1,42 @@
"use strict";
var expect = require('chai').expect;

var components = require("../src/index.js");

describe("Custom element validation", () => {
it("allows elements without options", () => {
components.registerElement("my-element");

return components.renderFragment("<my-element></my-element");
});

it("requires a non-empty name", () => {
var InvalidElement = components.newElement();
class InvalidElement {}
expect(() => {
components.registerElement("", { prototype: InvalidElement });
components.define("", InvalidElement);
}).to.throw(
/Registration failed for ''. Missing element name./
/The element name '' is not valid./
);
});

it("requires a hyphen in the element name", () => {
var InvalidElement = components.newElement();
class InvalidElement {}
expect(() => {
components.registerElement("invalidname", { prototype: InvalidElement });
components.define("invalidname", InvalidElement);
}).to.throw(
/Registration failed for 'invalidname'. Custom element names must contain a hyphen./
/The element name 'invalidname' is not valid./
);
});

it("doesn't allow elements to start with a hyphen", () => {
var InvalidElement = components.newElement();
class InvalidElement {}
expect(() => {
components.registerElement("-invalid-name", { prototype: InvalidElement });
components.define("-invalid-name", InvalidElement);
}).to.throw(
/Registration failed for '-invalid-name'. Custom element names must not start with a hyphen./
/The element name '-invalid-name' is not valid./
);
});

it("requires element names to be lower case", () => {
var InvalidElement = components.newElement();
class InvalidElement {}
expect(() => {
components.registerElement("INVALID-NAME", { prototype: InvalidElement });
components.define("INVALID-NAME", InvalidElement);
}).to.throw(
/Registration failed for 'INVALID-NAME'. Custom element names must not contain uppercase ASCII characters./
/The element name 'INVALID-NAME' is not valid./
);
});
});
52 changes: 28 additions & 24 deletions test/example-components.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
"use strict";
var expect = require('chai').expect;
var components = require("../src/index.js");

var linkify = require("linkifyjs/element");

describe("An example component:", () => {
describe("using static rendering", () => {
before(() => {
var StaticElement = components.newElement();
StaticElement.createdCallback = function () {
this.innerHTML = "Hi there";
};
beforeEach(() => {
components.reset();
});

components.registerElement("my-greeting", { prototype: StaticElement });
describe("using static rendering", () => {
beforeEach(() => {
class StaticElement extends components.HTMLElement {
connectedCallback() {
this.innerHTML = "Hi there";
}
}
components.define("my-greeting", StaticElement);
});

it("replaces its content with the given text", () => {
@@ -22,16 +27,16 @@ describe("An example component:", () => {
});

describe("using dynamic logic for rendering", () => {
before(() => {
var CounterElement = components.newElement();
beforeEach(() => {
var currentCount = 0;

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

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

it("dynamically changes its content", () => {
@@ -48,15 +53,14 @@ describe("An example component:", () => {
});

describe("parameterised by HTML content", () => {
before(() => {
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 });
beforeEach(() => {
class LinkifyElement extends components.HTMLElement {
connectedCallback(document) {
// Delegate the whole thing to a real front-end library!
linkify(this, { target: () => null, linkClass: "autolinked" }, document);
}
}
components.define("linkify-urls", LinkifyElement);
});

it("should be able to parse and manipulate it's content", () => {
106 changes: 59 additions & 47 deletions test/multiple-element-interactions-test.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
"use strict";
var expect = require('chai').expect;

var components = require("../src/index.js");

describe("When multiple DOM elements are present", () => {
beforeEach(() => {
components.reset();
});

describe("nested elements", () => {
it("are rendered correctly", () => {
var PrefixedElement = components.newElement();
PrefixedElement.createdCallback = function () {
this.innerHTML = "prefix:" + this.innerHTML;
};
components.registerElement("prefixed-element", {
prototype: PrefixedElement
});
class PrefixedElement extends components.HTMLElement {
connectedCallback() {
this.innerHTML = "prefix:" + this.innerHTML;
}
}
components.define("prefixed-element", PrefixedElement);

return components.renderFragment(
"<prefixed-element><prefixed-element>existing-content</prefixed-element></prefixed-element>"
@@ -25,13 +29,14 @@ describe("When multiple DOM elements are present", () => {

describe("parent elements", () => {
it("can see child elements", () => {
var ChildCountElement = components.newElement();
ChildCountElement.createdCallback = function () {
var newNode = this.doc.createElement("div");
newNode.textContent = this.childNodes.length + " children";
this.insertBefore(newNode, this.firstChild);
};
components.registerElement("child-count", { prototype: ChildCountElement });
class ChildCountElement extends components.HTMLElement {
connectedCallback() {
var newNode = this.doc.createElement("div");
newNode.textContent = this.childNodes.length + " children";
this.insertBefore(newNode, this.firstChild);
}
}
components.define("child-count", ChildCountElement);

return components.renderFragment(
"<child-count><div>A child</div><div>Another child</div></child-count>"
@@ -43,52 +48,59 @@ describe("When multiple DOM elements are present", () => {
});

it("can read attributes from custom child element's prototypes", () => {
var DataSource = components.newElement();
DataSource.data = [1, 2, 3];
components.registerElement("data-source", { prototype: DataSource });
class DataSource extends components.HTMLElement {
get data() {
return [10, 20, 30];
}
}
components.define("data-source", DataSource);

class DataDisplayer extends components.HTMLElement {
connectedCallback() {
return new Promise((resolve) => {
// Has to be async, as child node prototypes aren't set: http://stackoverflow.com/questions/36187227/
// This is a web components limitation generally. TODO: Find a nicer pattern for handle this.
setTimeout(() => {
var data = this.childNodes[0].data;
this.textContent = "Data: " + JSON.stringify(data);
resolve();
}, 0);
});
}
}

var DataDisplayer = components.newElement();
DataDisplayer.createdCallback = function () {
return new Promise((resolve) => {
// Has to be async, as child node prototypes aren't set: http://stackoverflow.com/questions/36187227/
// This is a web components limitation generally. TODO: Find a nicer pattern for handle this.
setTimeout(() => {
var data = this.childNodes[0].data;
this.textContent = "Data: " + JSON.stringify(data);
resolve();
}, 0);
});
};
components.registerElement("data-displayer", { prototype: DataDisplayer });
components.define("data-displayer", DataDisplayer);

return components.renderFragment(
"<data-displayer><data-source></data-source></data-displayer>"
).then((output) => {
expect(output).to.equal(
"<data-displayer>Data: [1,2,3]</data-displayer>"
"<data-displayer>Data: [10,20,30]</data-displayer>"
);
});
});

it("receive bubbling events from child elements", () => {
var EventRecorder = components.newElement();
EventRecorder.createdCallback = function (document) {
var resultsNode = document.createElement("p");
this.appendChild(resultsNode);
class EventRecorder extends components.HTMLElement {
connectedCallback(document) {
var resultsNode = document.createElement("p");
this.appendChild(resultsNode);

this.addEventListener("my-event", (event) => {
resultsNode.innerHTML = "Event received";
});
};
components.registerElement("event-recorder", { prototype: EventRecorder });
this.addEventListener("my-event", (event) => {
resultsNode.innerHTML = "Event received";
});
}
}
components.define("event-recorder", EventRecorder);

var EventElement = components.newElement();
EventElement.createdCallback = function () {
this.dispatchEvent(new components.dom.CustomEvent('my-event', {
bubbles: true
}));
};
components.registerElement("event-source", { prototype: EventElement });
class EventElement extends components.HTMLElement {
connectedCallback() {
this.dispatchEvent(new components.dom.CustomEvent('my-event', {
bubbles: true
}));
}
}
components.define("event-source", EventElement);

return components.renderFragment(
"<event-recorder><event-source></event-source></event-recorder>"
11 changes: 8 additions & 3 deletions test/programmatic-usage-test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
"use strict";
var expect = require('chai').expect;

var components = require("../src/index.js");

describe("Programmatic usage", () => {

// Pending until we decide what we want from this
it("returns the element constructor from the registration call", () => {
Copy link
Owner

Choose a reason for hiding this comment

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

Test changes look great, but the name needs updating too.

var NewElement = components.newElement();
var registrationResult = components.registerElement("my-element", { prototype: NewElement });
expect(NewElement.constructor).to.equal(registrationResult);
class NewElement extends components.HTMLElement {}
components.define("test-element", NewElement);

var klass = components.get("test-element");
expect(klass).to.equal(NewElement);
});
});