Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add native support for custom elements. #715

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
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
15 changes: 15 additions & 0 deletions .babelrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
Copy link
Author

Choose a reason for hiding this comment

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

From #683.

"presets": [
["env", {
"loose": true,
"exclude": ["transform-es2015-typeof-symbol"],
"targets": {
"browsers": ["last 2 versions", "IE >= 9"]
}
}]
],
"plugins": [
"transform-object-rest-spread",
"transform-react-jsx"
]
}
10 changes: 8 additions & 2 deletions src/dom/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,18 @@ import options from '../options';


/** Create an element with the given nodeName.
* @param {String} nodeName
* @param {String|Function} nodeName
* @param {Boolean} [isSvg=false] If `true`, creates an element within the SVG namespace.
* @returns {Element} node
*/
export function createNode(nodeName, isSvg) {
let node = isSvg ? document.createElementNS('http://www.w3.org/2000/svg', nodeName) : document.createElement(nodeName);
let node;
if (typeof nodeName==='function') {
Copy link
Author

Choose a reason for hiding this comment

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

This now has a branch so that it can create a custom element via newing the constructor. This covers both custom elements and customised built-in elements (is attribute) as it will correctly create the element based on how it was defined.

node = new nodeName();
nodeName = node.localName;
} else {
node = isSvg ? document.createElementNS('http://www.w3.org/2000/svg', nodeName) : document.createElement(nodeName);
}
node.normalizedNodeName = nodeName;
return node;
}
Expand Down
7 changes: 3 additions & 4 deletions src/vdom/diff.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { ATTR_KEY } from '../constants';
import { isSameNodeType, isNamedNode } from './index';
import { isSameNodeName, isSameNodeType } from './index';
import { buildComponentFromVNode } from './component';
import { createNode, setAccessor } from '../dom/index';
import { unmountComponent } from './component';
Expand Down Expand Up @@ -96,7 +96,7 @@ function idiff(dom, vnode, context, mountAll, componentRoot) {

// If the VNode represents a Component, perform a component diff:
let vnodeName = vnode.nodeName;
if (typeof vnodeName==='function') {
if (typeof vnodeName==='function' && !('nodeName' in vnodeName.prototype)) {
return buildComponentFromVNode(dom, vnode, context, mountAll);
}

Expand All @@ -106,8 +106,7 @@ function idiff(dom, vnode, context, mountAll, componentRoot) {


// If there's no existing element or it's the wrong type, create a new one:
vnodeName = String(vnodeName);
if (!dom || !isNamedNode(dom, vnodeName)) {
if (!dom || !isSameNodeName(dom, vnodeName) && dom.constructor !== vnodeName) {
out = createNode(vnodeName, isSvgMode);

if (dom) {
Expand Down
12 changes: 6 additions & 6 deletions src/vdom/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,18 @@ export function isSameNodeType(node, vnode, hydrating) {
return node.splitText!==undefined;
}
if (typeof vnode.nodeName==='string') {
return !node._componentConstructor && isNamedNode(node, vnode.nodeName);
return !node._componentConstructor && isSameNodeName(node, vnode.nodeName);
}
return hydrating || node._componentConstructor===vnode.nodeName;
return hydrating || node._componentConstructor===vnode.nodeName || node.constructor === vnode.nodeName;
Copy link
Member

Choose a reason for hiding this comment

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

Q: @treshugart - any idea if this will fail prior to CE registration?

Sorry for the delays on this PR.

Copy link
Author

@treshugart treshugart May 16, 2018

Choose a reason for hiding this comment

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

@developit

This part won't fail, but https://github.com/developit/preact/pull/715/files/3d68b2af246be137c3be8b43f3ea1be84fe59af0#diff-4935bb00e75b7854383877cbfd9d770fR13 would since we new it there.

The worst that can happen at this point, using constructors, is allow it to get to the point of creation to fail because it wasn't registered.

One thing to note here, may be that node.constructor has the possibility of being HTMLUnknownElement if a tag name is used to construct it. This means that if my-custom-element goes unregistered, then <my-custom-element /> is not the same thing as <MyCustomElement />, even though, theoretically they might share the same constructor, you just haven't associated them yet, thus causing it to try and construct the unregistered constructor.

Copy link
Author

@treshugart treshugart May 16, 2018

Choose a reason for hiding this comment

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

Something we're playing around in Skate with is auto-defining custom elements that aren't defined in a non-destructive way (extending to avoid registration conflicts). See: https://github.com/skatejs/skatejs/blob/master/packages/renderer-preact/src/index.js#L11. You probably don't want to do that here - at least yet - but that's really the only thing we can do besides error with a helpful message that the custom element hasn't been defined yet.

Unfortunately the only good way to check that would be to get some movement on WICG/webcomponents#566. Currently you have to pretty much try / catch, which I'm not sure you want to do in the critical path.

Copy link
Member

Choose a reason for hiding this comment

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

whoo yeah that's hefty. I'll be keeping an eye on 566...

}


/** Check if an Element has a given normalized name.
* @param {Element} node
* @param {String} nodeName
* @param {Element} node
* @param {mixed} nodeName
*/
export function isNamedNode(node, nodeName) {
return node.normalizedNodeName===nodeName || node.nodeName.toLowerCase()===nodeName.toLowerCase();
export function isSameNodeName(node, nodeName) {
return node.normalizedNodeName===nodeName || (nodeName.toLowerCase && node.nodeName.toLowerCase()===nodeName.toLowerCase());
}


Expand Down
117 changes: 117 additions & 0 deletions test/browser/custom-elements.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/** @jsx h */

import { h, render } from '../../src/preact';

window.customElements && describe('Custom elements', () => {
class CustomElement1 extends HTMLElement {}
class CustomElement2 extends HTMLElement {}
customElements.define('custom-element-1', CustomElement1);
customElements.define('custom-element-2', CustomElement2);

let refs, refsIndex, root, scratch;
const ref = () => e => e && (refs[refsIndex++] = e);
const tmp = jsx => (root = render(jsx, scratch, root));

beforeEach(() => {
refs = [];
refsIndex = 0;
scratch = document.createElement('div');
document.body.appendChild(scratch);
});

afterEach(() => {
scratch.parentNode.removeChild(scratch);
});

it('should not be replaced if they are the same', () => {
tmp(<CustomElement1 ref={ref()} />);
tmp(<CustomElement1 ref={ref()} />);
expect(refs[0]).to.equal(refs[1]);
});

it('should be replaced if they are different', () => {
tmp(<CustomElement1 ref={ref()} />);
tmp(<CustomElement2 ref={ref()} />);
expect(refs[0]).not.to.equal(refs[1]);
});

it('should replace keyed, different children', () => {
tmp(
<div>
<CustomElement1 key="0" ref={ref()} />
<CustomElement1 key="1" ref={ref()} />
<CustomElement1 key="2" ref={ref()} />
</div>
);
tmp(
<div>
<CustomElement2 key="0" ref={ref()} />
<CustomElement2 key="1" ref={ref()} />
<CustomElement2 key="2" ref={ref()} />
</div>
);
expect(refs[0]).to.not.equal(refs[3]);
expect(refs[1]).to.not.equal(refs[4]);
expect(refs[2]).to.not.equal(refs[5]);
});

it('should not replace keyed, same children', () => {
tmp(
<div>
<CustomElement1 key="0" ref={ref()} />
<CustomElement1 key="1" ref={ref()} />
<CustomElement1 key="2" ref={ref()} />
</div>
);
tmp(
<div>
<CustomElement1 key="0" ref={ref()} />
<CustomElement1 key="1" ref={ref()} />
<CustomElement1 key="2" ref={ref()} />
</div>
);
expect(refs[0]).to.equal(refs[3]);
expect(refs[1]).to.equal(refs[4]);
expect(refs[2]).to.equal(refs[5]);
});

it('should replace un-keyed (plucked), different children', () => {
tmp(
<div>
<CustomElement1 ref={ref()} />
<CustomElement1 ref={ref()} />
<CustomElement1 ref={ref()} />
</div>
);
tmp(
<div>
<CustomElement2 ref={ref()} />
<CustomElement2 ref={ref()} />
<CustomElement2 ref={ref()} />
</div>
);
expect(refs[0]).to.not.equal(refs[3]);
expect(refs[1]).to.not.equal(refs[4]);
expect(refs[2]).to.not.equal(refs[5]);
});

it('should not replace un-keyed (pluked), same children', () => {
tmp(
<div>
<CustomElement1 ref={ref()} />
<CustomElement1 ref={ref()} />
<CustomElement1 ref={ref()} />
</div>
);
tmp(
<div>
<CustomElement1 ref={ref()} />
<CustomElement1 ref={ref()} />
<CustomElement1 ref={ref()} />
</div>
);
expect(refs[0]).to.equal(refs[3]);
expect(refs[1]).to.equal(refs[4]);
expect(refs[2]).to.equal(refs[5]);
});
});
18 changes: 18 additions & 0 deletions test/custom-elements-es5-adapter.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/* eslint-disable */
Copy link
Author

Choose a reason for hiding this comment

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

This is the ES5 adapter needed to use ES5 "classes" with native custom elements.


(function () {
'use strict';

(()=>{'use strict';if(!window.customElements)return;const a=window.HTMLElement,b=window.customElements.define,c=window.customElements.get,d=new Map,e=new Map;let f=!1,g=!1;window.HTMLElement=function(){if(!f){const j=d.get(this.constructor),k=c.call(window.customElements,j);g=!0;const l=new k;return l}f=!1;},window.HTMLElement.prototype=a.prototype;Object.defineProperty(window,'customElements',{value:window.customElements,configurable:!0,writable:!0}),Object.defineProperty(window.customElements,'define',{value:(j,k)=>{const l=k.prototype,m=class extends a{constructor(){super(),Object.setPrototypeOf(this,l),g||(f=!0,k.call(this)),g=!1;}},n=m.prototype;m.observedAttributes=k.observedAttributes,n.connectedCallback=l.connectedCallback,n.disconnectedCallback=l.disconnectedCallback,n.attributeChangedCallback=l.attributeChangedCallback,n.adoptedCallback=l.adoptedCallback,d.set(k,j),e.set(j,k),b.call(window.customElements,j,m);},configurable:!0,writable:!0}),Object.defineProperty(window.customElements,'get',{value:(j)=>e.get(j),configurable:!0,writable:!0});})();

/**
@license
Copyright (c) 2017 The Polymer Project Authors. All rights reserved.
This code may only be used under the BSD style license found at http://polymer.github.io/LICENSE.txt
The complete set of authors may be found at http://polymer.github.io/AUTHORS.txt
The complete set of contributors may be found at http://polymer.github.io/CONTRIBUTORS.txt
Code distributed by Google as part of the polymer project is also
subject to an additional IP rights grant found at http://polymer.github.io/PATENTS.txt
*/

}());
4 changes: 4 additions & 0 deletions test/karma.conf.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ module.exports = function(config) {
customLaunchers: sauceLabs ? sauceLabsLaunchers : travisLaunchers,

files: [
// We can't load this up front because it's ES2015 and we need it only
// for certain tests that run under those conditions. We also can't load
// it via CDN because { included: false } won't work.
{ pattern: 'custom-elements-es5-adapter.js', included: false },
Copy link
Author

Choose a reason for hiding this comment

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

Unfortunately, I couldn't find a way to get karma to conditionally load stuff from a CDN, so I had to include it.

{ pattern: 'polyfills.js', watched: false },
{ pattern: '{browser,shared}/**.js', watched: false }
],
Expand Down
8 changes: 8 additions & 0 deletions test/polyfills.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,11 @@ import 'core-js/es6/map';
import 'core-js/fn/array/fill';
import 'core-js/fn/array/from';
import 'core-js/fn/object/assign';

// We add the ES5 adapter because src / test are converted to ES5 and native
Copy link
Author

Choose a reason for hiding this comment

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

Comment is self-explanatory, but thought I should highlight this as part of the caveats listed in the main description of the PR.

Copy link

Choose a reason for hiding this comment

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

@WebReflection's ideas from babel-plugin-transform-builtin-classes have been incorporated into Babel 7, so at some point this adapter will not be needed.

I already use WebComponents.js without native-shim in my projects, transpiling with Babel 6 + babel-plugin-transform-builtin-classes.

Copy link

@trusktr trusktr Feb 2, 2018

Choose a reason for hiding this comment

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

The result of using babel-plugin-transform-builtin-classes is nice because the transpiled code runs in all browsers (old and new).

// custom elements uses native ES2015. Unfortunately. This needs to be done
// sync so tests that assert this functionality will have the ES5 shim loaded
// by the time they run.
if (window.customElements) {
document.write('<script src="https://unpkg.com/@webcomponents/[email protected]/custom-elements-es5-adapter.js"></script>');
}