Skip to content

Commit

Permalink
Simplify WebComponent by consolidating _init/_deinit with init/deinit.
Browse files Browse the repository at this point in the history
Added childConnectedCallback and childDisconnectedCallback for
subclasses to implement in order to run logic when a child is connected
or disconnected.

Update MotorHTMLBase and MotorHTMLNode classes. Basically added
childConnectedCallback and childDisconnectedCallback methods to the base
class to mirror the connections on the imperative side, which makes the
API parent-to-child instead of child-to-parent.

For now, the error state in which motor-nodes are attached to
non-motor-node elements is ignored, and an app with such state will
silently fail. It will be too complex and messy to make it work due to
current Custom Elements API limitations. Some features that would help are
described in issues #527 and #550 of w3c/webcomponents.

This is partial work for issue #40. Next we need to make parents observe
<content> or <slot> elements depending on version of the Custom Elements
API in order to make ShadowDOM work.
  • Loading branch information
trusktr committed Aug 24, 2016
1 parent 49715ce commit 0d49295
Show file tree
Hide file tree
Showing 2 changed files with 66 additions and 93 deletions.
58 changes: 2 additions & 56 deletions src/motor-html/node.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,63 +9,19 @@ import Node from '../motor/Node'
import Transformable from '../motor/Transformable'
import Sizeable from '../motor/Sizeable'
import MotorHTMLBase from './base'
import MotorHTMLScene from './scene'

// XXX we'll export the class directly for v1 Custom Elements, and encourage
// end users to define the name of the element as they see fit. We won't
// define the name ourselves like we do here.
let MotorHTMLNode = document.registerElement('motor-node',
class MotorHTMLNode extends MotorHTMLBase {

createdCallback() {
super.createdCallback()

// true if MotorHTMLNode is mounted improperly (not mounted in another
// MotorHTMLNode or MotorHTMLScene element.)
this._attachError = false
}

connectedCallback() {

// Check that motor-nodes are mounted to motor-scenes or
// motor-nodes. Scene can be mounted to any element. In the future
// we could inspect the scene mount point, and advise about posisble
// styling issues (f.e. making the scene container have a height).
//
// XXX: different check needed when using is="" attributes. For now,
// we'll discourage use of the awkward is="" attribute.
if (
!(
this.parentNode instanceof MotorHTMLNode ||
this.parentNode instanceof MotorHTMLScene
)
|| this.parentNode._attachError // TODO, #40
) {

this._attachError = true
throw new Error('<motor-node> elements must be appended only to <motor-scene> or other <motor-node> elements.')
}

super.connectedCallback()
}

getStyles() {
return styles
}

init() {
super.init()

// Attach this motor-node's Node to the parent motor-node's
// Node (doesn't apply to motor-scene, which doesn't have a
// parent to attach to).
//
// TODO: prevent this call if connectedCallback happened to call to
// addChild on the imperative side.
this.parentNode.imperativeCounterpart.addChild(this.imperativeCounterpart)
}

// this is called in connectedCallback, at which point this element has a
// this is called by DeclarativeBase#init, which is called by
// WebComponent#connectedCallback, at which point this element has a
// parentNode.
// @override
_makeImperativeCounterpart() {
Expand All @@ -74,16 +30,6 @@ class MotorHTMLNode extends MotorHTMLBase {
})
}

// TODO XXX: remove corresponding imperative Node from it's parent.
disconnectedCallback() {
if (this._attachError) {
this._attachError = false
return
}

super.disconnectedCallback()
}

attributeChangedCallback(attribute, oldValue, newValue) {
this._updateNodeProperty(attribute, oldValue, newValue)
}
Expand Down
101 changes: 64 additions & 37 deletions src/motor-html/web-component.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,17 @@ import jss from '../jss'
// Very very stupid hack needed for Safari in order for us to be able to extend
// the HTMLElement class. See:
// https://github.com/google/traceur-compiler/issues/1709
//if (typeof window.HTMLElement != 'function') {
//const _HTMLElement = function HTMLElement(){}
//_HTMLElement.prototype = window.HTMLElement.prototype
//window.HTMLElement = _HTMLElement
//}
if (typeof window.HTMLElement != 'function') {
const _HTMLElement = function HTMLElement(){}
_HTMLElement.prototype = window.HTMLElement.prototype
window.HTMLElement = _HTMLElement
}

// XXX: we can improve by clearing items after X amount of time.
// XXX: Maybe we can improve by clearing items after X amount of time?
const classCache = new Map

let stylesheets = new WeakMap
let instanceCountByConstructor = new WeakMap
const stylesheets = new WeakMap
const instanceCountByConstructor = new WeakMap

function hasHTMLElementPrototype(constructor) {
if (!constructor) return false
Expand All @@ -29,19 +29,19 @@ function hasHTMLElementPrototype(constructor) {
* making a new Custom Element class.
*
* @example
* const WebComponent = makeWebComponentBaseClass(HTMLButtonElement)
* const WebComponent = WebComponentMixin(HTMLButtonElement)
* class AwesomeButton extends WebComponent { ... }
*
* @param {Function} elementClass The class to that the generated WebComponent
* @param {Function} elementClass The class that the generated WebComponent
* base class will extend from.
*/
export default
function makeWebComponentBaseClass(elementClass) {
function WebComponentMixin(elementClass) {
if (!elementClass) elementClass = HTMLElement

if (!hasHTMLElementPrototype(elementClass)) {
throw new TypeError(
'The argument to makeWebComponentBaseClass must be a constructor that extends from or is HTMLElement.'
'The argument to WebComponentMixin must be a constructor that extends from or is HTMLElement.'
)
}

Expand All @@ -51,7 +51,6 @@ function makeWebComponentBaseClass(elementClass) {
return classCache.get(elementClass)

// otherwise, create it.

class WebComponent extends elementClass {

// constructor() is used in v1 Custom Elements instead of
Expand All @@ -71,14 +70,16 @@ function makeWebComponentBaseClass(elementClass) {

// TODO: link to docs.
throw new Error(`
You cannot call this class directly without first registering it
You cannot instantiate this class directly without first registering it
with \`document.registerElement(...)\`. See an example at http://....
`)

}

// Throw an error if no Custom Elements API exists.
if (!document.registerElement && !customElements.define) {

// TODO: link to docs.
throw new Error(`
Your browser does not support the Custom Elements API. You'll
need to install a polyfill. See how at http://....
Expand All @@ -87,32 +88,41 @@ function makeWebComponentBaseClass(elementClass) {
}

// otherwise the V1 API exists, so call the createdCallback, which
// is what Custom Elements v0 would call, and we're putting
// instantiation logic there instead of here in the constructor so
// that the API is backwards compatible.
// is what Custom Elements v0 would call by default. Subclasses of
// WebComponent should put instantiation logic in createdCallback
// instead of in a custom constructor if backwards compatibility is
// to be maintained.
this.createdCallback()
}

createdCallback() {
this._attached = false
this._initialized = false

//this.root....addEventListener('slotchange', function() {
//let slot = ...
//for (el in slot) {
//el.slottedCallback(slot)
//}
//})
// TODO issue #40
const observer = new MutationObserver(changes => {
for (let change of changes) {
if (change.type != 'childList') continue

for (let node of change.addedNodes)
this.childConnectedCallback(node)

for (let node of change.removedNodes)
this.childDisconnectedCallback(node)
}
})
observer.observe(this, { childList: true })
}

//slottedCallback(slot) {
//}
// Subclasses can implement these.
childConnectedCallback(child) {}
childDisconnectedCallback(child) {}

connectedCallback() {
this._attached = true

if (!this._initialized) {
this._init()
this.init()
this._initialized = true
}
}
Expand Down Expand Up @@ -147,30 +157,31 @@ function makeWebComponentBaseClass(elementClass) {
// deferring to the next tick we'll be able to know if the element
// was re-attached or not in order to clean up or not). Note that
// appendChild can be used to move an element to another parent
// element, in which case attachedCallback and detachedCallback
// element, in which case connectedCallback and disconnectedCallback
// both get called, and in which case we don't necessarily want to
// clean up. If the element gets re-attached before the next tick
// (for example, gets moved), then we want to preserve the
// associated stylesheet and other stuff that would be cleaned up
// by an extending class' _cleanUp method by not running the
// following this._deinit() call.
// following this.deinit() call.
await Promise.resolve() // deferr to the next tick.

// As mentioned in the previous comment, if the element was not
// re-attached in the last tick (for example, it was moved to
// another element), then clean up.
//
// XXX (performance): Should we coordinate this._deinit() with the
// XXX (performance): Should we coordinate this.deinit() with the
// animation loop to prevent jank?
if (!this._attached && this._initialized) {
this._deinit()
this.deinit()
}
}
detachedCallback() { this.disconnectedCallback() } // back-compat

_destroyStylesheet() {
instanceCountByConstructor.set(this.constructor,
instanceCountByConstructor.get(this.constructor) - 1)

if (instanceCountByConstructor.get(this.constructor) === 0) {
stylesheets.get(this.constructor).detach()
stylesheets.delete(this.constructor)
Expand All @@ -186,24 +197,40 @@ function makeWebComponentBaseClass(elementClass) {
throw new Error('Your component must define a getStyles method, which returns the JSS-compatible JSON-formatted styling of your component.')
}

_init() {

/**
* Init is called exactly once, the first time this element is connected
* into the DOM. When an element is disconnected then connected right
* away within the same tick, init() is not fired again. However, if an
* element is disconnected and then some time passes and the current
* tick completes, then deinit() will be called, and the next time that
* the element is connected back into DOM init() will be called again.
*
* Subclasses should extend this to add such logic.
*/
init() {
this._createStylesheet()

// TODO: Find a better pattern that doesn't rely on the class name.
this.classList.add(this.stylesheet.classes[this.constructor.name])

this.init()
}
init() { /* to be defined by child class */ }

_deinit() {
/**
* This is the reciprocal of init(). It will be called when an element
* has been disconnected but not re-connected within the same tick.
*
* The reason that init() and deinit() exist is so that if an element is
* moved from one place to another within the same synchronous tick,
* that deinit and init logic will not fire unnecessarily. If logic is
* needed in that case, then connectedCallback and disconnectedCallback
* can be used directly instead.
*/
deinit() {
// XXX: We can clean up the style after some time, for example like 1
// minute, or something, instead of instantly.
this._destroyStylesheet()
this._initialized = false
this.deinit()
}
deinit() { /* to be defined by child class */ }
}

classCache.set(elementClass, WebComponent)
Expand Down

0 comments on commit 0d49295

Please sign in to comment.