src/core/component.js

Summary

Maintainability
A
35 mins
Test Coverage
import {
  ATTRIBUTES_KEY_SYMBOL,
  COMPONENTS_IMPLEMENTATION_MAP,
  DOM_COMPONENT_INSTANCE_PROPERTY,
  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 '../globals'
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, panic} from '@riotjs/util/misc'
import $ from 'bianco.query'
import {camelToDashCase} from '@riotjs/util/strings'
import cssManager from './css-manager'
import curry from 'curri'
import {getName} from '../utils/dom'
import {isFunction} from '@riotjs/util/checks'
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
}

/**
 * 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   {Object} components - object containing the nested components
 * @returns {TemplateChunk} template chunk object
 */
function componentTemplateFactory(template, components) {
  return template(
    createTemplate,
    expressionTypes,
    bindingTypes,
    name => {
      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 [el] = args
      bindDOMNodeToComponentObject(el, component)
    }

    component[method](...args)

    return component
  })
}

/**
 * Create the component interface needed for the @riotjs/dom-bindings tag bindings
 * @param   {string} options.css - component css
 * @param   {Function} options.template - functon that will return the dom-bindings template function
 * @param   {Object} options.exports - component interface
 * @param   {string} options.name - component name
 * @returns {Object} component like interface
 */
export function createComponent({css, template, exports, name}) {
  const templateFn = template ? componentTemplateFactory(
    template,
    exports ? createSubcomponents(exports.components) : {}
  ) : 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,
      [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(Object.create(component), {
        mount(element, state = {}, parentScope) {
          this[ATTRIBUTES_KEY_SYMBOL] = createAttributeBindings(element, attributes).mount(parentScope)

          defineProperty(this, PROPS_KEY, Object.freeze({
            ...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[PARENT_KEY_SYMBOL] = parentScope

          this[ON_MOUNTED_KEY](this[PROPS_KEY], this[STATE_KEY])

          return this
        },
        update(state = {}, parentScope) {
          if (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({
            ...props,
            ...newProps
          }))

          this[STATE_KEY] = computeState(this[STATE_KEY], state)

          this[ON_BEFORE_UPDATE_KEY](this[PROPS_KEY], this[STATE_KEY])
          this[TEMPLATE_KEY_SYMBOL].update(this, this[PARENT_KEY_SYMBOL])
          this[ON_UPDATED_KEY](this[PROPS_KEY], this[STATE_KEY])

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