src/core/component.js

Summary

Maintainability
B
4 hrs
Test Coverage
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)
}