src/core/component.js
import {
ATTRIBUTES_KEY_SYMBOL,
COMPONENTS_IMPLEMENTATION_MAP,
DOM_COMPONENT_INSTANCE_PROPERTY,
IS_COMPONENT_UPDATING,
IS_DIRECTIVE,
IS_PURE_SYMBOL,
MOUNT_METHOD_KEY,
ON_BEFORE_MOUNT_KEY,
ON_BEFORE_UNMOUNT_KEY,
ON_BEFORE_UPDATE_KEY,
ON_MOUNTED_KEY,
ON_UNMOUNTED_KEY,
ON_UPDATED_KEY,
PARENT_KEY_SYMBOL,
PLUGINS_SET,
PROPS_KEY,
ROOT_KEY,
SHOULD_UPDATE_KEY,
SLOTS_KEY,
STATE_KEY,
TEMPLATE_KEY_SYMBOL,
UNMOUNT_METHOD_KEY,
UPDATE_METHOD_KEY
} from '@riotjs/util/constants'
import {
autobindMethods,
callOrAssign,
noop
} from '@riotjs/util/functions'
import {
bindingTypes,
createExpression,
template as createTemplate,
expressionTypes
} from '@riotjs/dom-bindings'
import {
defineDefaults,
defineProperties,
defineProperty
} from '@riotjs/util/objects'
import {
evaluateAttributeExpressions,
memoize,
panic
} from '@riotjs/util/misc'
import {isFunction, isObject} from '@riotjs/util/checks'
import $ from 'bianco.query'
import {DOMattributesToObject} from '@riotjs/util/dom'
import {camelToDashCase} from '@riotjs/util/strings'
import cssManager from './css-manager'
import curry from 'curri'
import {getName} from '../utils/dom'
import {set as setAttr} from 'bianco.attr'
const COMPONENT_CORE_HELPERS = Object.freeze({
// component helpers
$(selector){ return $(selector, this.root)[0] },
$$(selector){ return $(selector, this.root) }
})
const PURE_COMPONENT_API = Object.freeze({
[MOUNT_METHOD_KEY]: noop,
[UPDATE_METHOD_KEY]: noop,
[UNMOUNT_METHOD_KEY]: noop
})
const COMPONENT_LIFECYCLE_METHODS = Object.freeze({
[SHOULD_UPDATE_KEY]: noop,
[ON_BEFORE_MOUNT_KEY]: noop,
[ON_MOUNTED_KEY]: noop,
[ON_BEFORE_UPDATE_KEY]: noop,
[ON_UPDATED_KEY]: noop,
[ON_BEFORE_UNMOUNT_KEY]: noop,
[ON_UNMOUNTED_KEY]: noop
})
const MOCKED_TEMPLATE_INTERFACE = {
...PURE_COMPONENT_API,
clone: noop,
createDOM: noop
}
/**
* Performance optimization for the recursive components
* @param {RiotComponentWrapper} componentWrapper - riot compiler generated object
* @returns {Object} component like interface
*/
const memoizedCreateComponent = memoize(createComponent)
/**
* Evaluate the component properties either from its real attributes or from its initial user properties
* @param {HTMLElement} element - component root
* @param {Object} initialProps - initial props
* @returns {Object} component props key value pairs
*/
function evaluateInitialProps(element, initialProps = {}) {
return {
...DOMattributesToObject(element),
...callOrAssign(initialProps)
}
}
/**
* Bind a DOM node to its component object
* @param {HTMLElement} node - html node mounted
* @param {Object} component - Riot.js component object
* @returns {Object} the component object received as second argument
*/
const bindDOMNodeToComponentObject = (node, component) => node[DOM_COMPONENT_INSTANCE_PROPERTY] = component
/**
* Wrap the Riot.js core API methods using a mapping function
* @param {Function} mapFunction - lifting function
* @returns {Object} an object having the { mount, update, unmount } functions
*/
function createCoreAPIMethods(mapFunction) {
return [
MOUNT_METHOD_KEY,
UPDATE_METHOD_KEY,
UNMOUNT_METHOD_KEY
].reduce((acc, method) => {
acc[method] = mapFunction(method)
return acc
}, {})
}
/**
* Factory function to create the component templates only once
* @param {Function} template - component template creation function
* @param {RiotComponentWrapper} componentWrapper - riot compiler generated object
* @returns {TemplateChunk} template chunk object
*/
function componentTemplateFactory(template, componentWrapper) {
const components = createSubcomponents(componentWrapper.exports ? componentWrapper.exports.components : {})
return template(
createTemplate,
expressionTypes,
bindingTypes,
name => {
// improve support for recursive components
if (name === componentWrapper.name) return memoizedCreateComponent(componentWrapper)
// return the registered components
return components[name] || COMPONENTS_IMPLEMENTATION_MAP.get(name)
}
)
}
/**
* Create a pure component
* @param {Function} pureFactoryFunction - pure component factory function
* @param {Array} options.slots - component slots
* @param {Array} options.attributes - component attributes
* @param {Array} options.template - template factory function
* @param {Array} options.template - template factory function
* @param {any} options.props - initial component properties
* @returns {Object} pure component object
*/
function createPureComponent(pureFactoryFunction, { slots, attributes, props, css, template }) {
if (template) panic('Pure components can not have html')
if (css) panic('Pure components do not have css')
const component = defineDefaults(
pureFactoryFunction({ slots, attributes, props }),
PURE_COMPONENT_API
)
return createCoreAPIMethods(method => (...args) => {
// intercept the mount calls to bind the DOM node to the pure object created
// see also https://github.com/riot/riot/issues/2806
if (method === MOUNT_METHOD_KEY) {
const [element] = args
// mark this node as pure element
defineProperty(element, IS_PURE_SYMBOL, true)
bindDOMNodeToComponentObject(element, component)
}
component[method](...args)
return component
})
}
/**
* Create the component interface needed for the @riotjs/dom-bindings tag bindings
* @param {RiotComponentWrapper} componentWrapper - riot compiler generated object
* @param {string} componentWrapper.css - component css
* @param {Function} componentWrapper.template - function that will return the dom-bindings template function
* @param {Object} componentWrapper.exports - component interface
* @param {string} componentWrapper.name - component name
* @returns {Object} component like interface
*/
export function createComponent(componentWrapper) {
const {css, template, exports, name} = componentWrapper
const templateFn = template ? componentTemplateFactory(
template,
componentWrapper
) : MOCKED_TEMPLATE_INTERFACE
return ({slots, attributes, props}) => {
// pure components rendering will be managed by the end user
if (exports && exports[IS_PURE_SYMBOL])
return createPureComponent(
exports,
{ slots, attributes, props, css, template }
)
const componentAPI = callOrAssign(exports) || {}
const component = defineComponent({
css,
template: templateFn,
componentAPI,
name
})({slots, attributes, props})
// notice that for the components create via tag binding
// we need to invert the mount (state/parentScope) arguments
// the template bindings will only forward the parentScope updates
// and never deal with the component state
return {
mount(element, parentScope, state) {
return component.mount(element, state, parentScope)
},
update(parentScope, state) {
return component.update(state, parentScope)
},
unmount(preserveRoot) {
return component.unmount(preserveRoot)
}
}
}
}
/**
* Component definition function
* @param {Object} implementation - the componen implementation will be generated via compiler
* @param {Object} component - the component initial properties
* @returns {Object} a new component implementation object
*/
export function defineComponent({css, template, componentAPI, name}) {
// add the component css into the DOM
if (css && name) cssManager.add(name, css)
return curry(enhanceComponentAPI)(defineProperties(
// set the component defaults without overriding the original component API
defineDefaults(componentAPI, {
...COMPONENT_LIFECYCLE_METHODS,
[PROPS_KEY]: {},
[STATE_KEY]: {}
}), {
// defined during the component creation
[SLOTS_KEY]: null,
[ROOT_KEY]: null,
// these properties should not be overriden
...COMPONENT_CORE_HELPERS,
name,
css,
template
})
)
}
/**
* Create the bindings to update the component attributes
* @param {HTMLElement} node - node where we will bind the expressions
* @param {Array} attributes - list of attribute bindings
* @returns {TemplateChunk} - template bindings object
*/
function createAttributeBindings(node, attributes = []) {
const expressions = attributes.map(a => createExpression(node, a))
const binding = {}
return Object.assign(binding, {
expressions,
...createCoreAPIMethods(method => scope => {
expressions.forEach(e => e[method](scope))
return binding
})
})
}
/**
* Create the subcomponents that can be included inside a tag in runtime
* @param {Object} components - components imported in runtime
* @returns {Object} all the components transformed into Riot.Component factory functions
*/
function createSubcomponents(components = {}) {
return Object.entries(callOrAssign(components))
.reduce((acc, [key, value]) => {
acc[camelToDashCase(key)] = createComponent(value)
return acc
}, {})
}
/**
* Run the component instance through all the plugins set by the user
* @param {Object} component - component instance
* @returns {Object} the component enhanced by the plugins
*/
function runPlugins(component) {
return [...PLUGINS_SET].reduce((c, fn) => fn(c) || c, component)
}
/**
* Compute the component current state merging it with its previous state
* @param {Object} oldState - previous state object
* @param {Object} newState - new state givent to the `update` call
* @returns {Object} new object state
*/
function computeState(oldState, newState) {
return {
...oldState,
...callOrAssign(newState)
}
}
/**
* Add eventually the "is" attribute to link this DOM node to its css
* @param {HTMLElement} element - target root node
* @param {string} name - name of the component mounted
* @returns {undefined} it's a void function
*/
function addCssHook(element, name) {
if (getName(element) !== name) {
setAttr(element, IS_DIRECTIVE, name)
}
}
/**
* Component creation factory function that will enhance the user provided API
* @param {Object} component - a component implementation previously defined
* @param {Array} options.slots - component slots generated via riot compiler
* @param {Array} options.attributes - attribute expressions generated via riot compiler
* @returns {Riot.Component} a riot component instance
*/
export function enhanceComponentAPI(component, {slots, attributes, props}) {
return autobindMethods(
runPlugins(
defineProperties(isObject(component) ? Object.create(component) : component, {
mount(element, state = {}, parentScope) {
// any element mounted passing through this function can't be a pure component
defineProperty(element, IS_PURE_SYMBOL, false)
this[PARENT_KEY_SYMBOL] = parentScope
this[ATTRIBUTES_KEY_SYMBOL] = createAttributeBindings(element, attributes).mount(parentScope)
defineProperty(this, PROPS_KEY, Object.freeze({
...evaluateInitialProps(element, props),
...evaluateAttributeExpressions(this[ATTRIBUTES_KEY_SYMBOL].expressions)
}))
this[STATE_KEY] = computeState(this[STATE_KEY], state)
this[TEMPLATE_KEY_SYMBOL] = this.template.createDOM(element).clone()
// link this object to the DOM node
bindDOMNodeToComponentObject(element, this)
// add eventually the 'is' attribute
component.name && addCssHook(element, component.name)
// define the root element
defineProperty(this, ROOT_KEY, element)
// define the slots array
defineProperty(this, SLOTS_KEY, slots)
// before mount lifecycle event
this[ON_BEFORE_MOUNT_KEY](this[PROPS_KEY], this[STATE_KEY])
// mount the template
this[TEMPLATE_KEY_SYMBOL].mount(element, this, parentScope)
this[ON_MOUNTED_KEY](this[PROPS_KEY], this[STATE_KEY])
return this
},
update(state = {}, parentScope) {
if (parentScope) {
this[PARENT_KEY_SYMBOL] = parentScope
this[ATTRIBUTES_KEY_SYMBOL].update(parentScope)
}
const newProps = evaluateAttributeExpressions(this[ATTRIBUTES_KEY_SYMBOL].expressions)
if (this[SHOULD_UPDATE_KEY](newProps, this[PROPS_KEY]) === false) return
defineProperty(this, PROPS_KEY, Object.freeze({
...this[PROPS_KEY],
...newProps
}))
this[STATE_KEY] = computeState(this[STATE_KEY], state)
this[ON_BEFORE_UPDATE_KEY](this[PROPS_KEY], this[STATE_KEY])
// avoiding recursive updates
// see also https://github.com/riot/riot/issues/2895
if (!this[IS_COMPONENT_UPDATING]) {
this[IS_COMPONENT_UPDATING] = true
this[TEMPLATE_KEY_SYMBOL].update(this, this[PARENT_KEY_SYMBOL])
}
this[ON_UPDATED_KEY](this[PROPS_KEY], this[STATE_KEY])
this[IS_COMPONENT_UPDATING] = false
return this
},
unmount(preserveRoot) {
this[ON_BEFORE_UNMOUNT_KEY](this[PROPS_KEY], this[STATE_KEY])
this[ATTRIBUTES_KEY_SYMBOL].unmount()
// if the preserveRoot is null the template html will be left untouched
// in that case the DOM cleanup will happen differently from a parent node
this[TEMPLATE_KEY_SYMBOL].unmount(this, this[PARENT_KEY_SYMBOL], preserveRoot === null ? null : !preserveRoot)
this[ON_UNMOUNTED_KEY](this[PROPS_KEY], this[STATE_KEY])
return this
}
})
),
Object.keys(component).filter(prop => isFunction(component[prop]))
)
}
/**
* Component initialization function starting from a DOM node
* @param {HTMLElement} element - element to upgrade
* @param {Object} initialProps - initial component properties
* @param {string} componentName - component id
* @returns {Object} a new component instance bound to a DOM node
*/
export function mountComponent(element, initialProps, componentName) {
const name = componentName || getName(element)
if (!COMPONENTS_IMPLEMENTATION_MAP.has(name)) panic(`The component named "${name}" was never registered`)
const component = COMPONENTS_IMPLEMENTATION_MAP.get(name)({
props: initialProps
})
return component.mount(element)
}