substance
Version:
Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing system. It is developed to power our online editing platform [Substance](http://substance.io).
1,345 lines (1,260 loc) • 44 kB
JavaScript
import isFunction from '../util/isFunction'
import isString from '../util/isString'
import flatten from '../util/flatten'
import substanceGlobals from '../util/substanceGlobals'
import getClassName from '../util/_getClassName'
import hasOwnProperty from '../util/hasOwnProperty'
import DefaultDOMElement from './DefaultDOMElement'
import VirtualElement from './VirtualElement'
const TOP_LEVEL_ELEMENT = Symbol('TOP_LEVEL_ELEMENT')
/**
* # Rendering Algorithm
*
* ## Introduction
*
* The challenges of virtual rendering, particularly with the Substance specialities, namely
* fully initialized component after construction.
*
* - Dependency Injection via constructor requires an existing parent.
* As a consequence a component tree must be constructed from top
* to down.
*
* - The earliest time to evaluate `$$(MyComponent)`, is when it has been
* attached to an existing component. I.e., to run `MyComponent.render()` an
* instance of `MyComponent` is needed, which can only be created with an
* existing parent component.
*
* - In general, it is *not* possible to have a naturally descending rendering
* algorithm, i.e. a simple recursion calling `render()` and creating or
* updating Components on the way, preserving a simple stack-trace.
* Instead, it requires calling `render()` on one level, then doing comparisons
* with the existing tree to be able to reuse components, and then descend into
* the sub-tree.
*
* - If components are passed down via props, things get even more difficult.
* For example, consider a situation where components are passed via props:
* ```
* render($$) {
* return $$('div').append(
* $$(Wrapper, {
* foo: $$(MyComponent)
* })
* )
* }
* ```
* At the time when this component gets rendered, `MyComponent` can not be
* instantiated, as it is not known what `Wrapper` actually does with it.
* While the general approach is working from top-to-down, in this case it has
* a bottom-to-up nature, i.e., the child needs to be rendered to know what to
* do with the passed component.
*
* Particularly, this is problematic when the passed component has a reference:
* ```
* render($$) {
* return $$('div').append(
* $$(Wrapper, {
* foo: $$(MyComponent).ref('foo')
* })
* )
* }
* ```
* As nothing is known at the time of descending about the content of `Wrapper`
* the rendering algorithm can not tell that it ought to be preserved. For now,
* the correct way to deal with this situation is to use a reference for the
* wrapper as well:
* ```
* render($$) {
* return $$('div').append(
* $$(Wrapper, {
* foo: $$(MyComponent).ref('foo')
* }).ref('wrapper')
* )
* }
* ```
*
* ## Algorithm
*
* For a given Component `comp`:
*
* 1. Capturing a virtual DOM
* 1.1. Create a virtual DOM element by calling `comp.render()`
* 1.2. Map virtual elements to existing elements
* 1.3. Apply 1.1. and 1.2. recursively for every virtual Component
* 2. Update `comp.el` given a virtual DOM element
*
* Notes:
* - 1.2. is necessary to preserve components and capture DOM updates using the
* correct instances
* - 2. can be seen as an independent task, updating one DOM given a second one.
*
* ## Implementation
*
* > TODO: flesh this out
*
* - Rendering happens in two stages: capture and render/update.
* In the capturing stage a VirtualComponent tree is created by calling
* `Component.render()` from top to down recursively. In the rendering stage
* DOM elements are created and updated.
* - Refs: the programmer can use ref(id) to register a reference to a child
* component. Referenced components are always reused when rerendering, i.e.
* not disposed. For other elements, there is no guarantee that the component
* and its DOM element is reused. The RenderingEngine may do so if possible,
* e.g. if the structure does not change.
*
* ## TODO
*
* - reuse unmapped elements that are compatible during rendering
* - rethink 'Forwarding Components' regarding parent-child relationship.
* ATM, there is no extra model for that hierarchy than the DOM, only
* `comp.parent` reflects the relationship correctly
*
* These ideas could improve the implementation:
* - remove outlets: outlets are just another way to change props.
*/
export default class RenderingEngine {
constructor (options = {}) {
this.componentFactory = options.componentFactory
if (!this.componentFactory) throw new Error("'componentFactory' is mandatory")
this.elementFactory = options.elementFactory || DefaultDOMElement.createDocument('html')
if (!this.elementFactory) throw new Error("'elementFactory' is mandatory")
}
/**
* @param {string | Class<Component>} type a HTML element name, or Component class
* @param {object} props
* @param {...any} children
*/
static createVirtualElement (type, props, ...children) {
const renderingContext = _getRenderingContext()
const createElement = renderingContext.$$
const _props = {}
let _class = null
const _styles = null
const _attributes = {}
const _htmlProps = {}
const _eventListeners = []
let _ref = null
if (props) {
const keys = Object.keys(props)
for (const key of keys) {
if (!hasOwnProperty(props, key)) continue
const val = props[key]
// ATTENTION: assuming that all event handlers start with 'on'
const m = /^on([A-Za-z]+)$/.exec(key)
if (m) {
// ATTENTION: IMO all native events are lower case
_eventListeners.push([m[1].toLowerCase(), val])
} else if (key === 'ref') {
_ref = val
} else if (isString(type)) {
switch (key) {
case 'class':
case 'className': {
_class = val
break
}
case 'style': {
if (!isString(val)) {
throw new Error('HTML attribute "style" must be a CSS string.')
}
_attributes.style = val
break
}
// ATTENTION: this list is utterly incomplete and IMO even incorrect
// TODO: Would need a complete list of 'reflected' properties, i.e. properties that are identical to attributes
// vs those who are only initialized with the attribute value. This should be solved in Substance generally (DOMElement, VirtualElement, and RenderingEngine)
// For now, this just represents 'non-reflected' properties that we have needed so far
// - value: needed for all types of input elements
// - checked: input fields of type 'checkbox'
// - selected: options of input fields of type 'select'
case 'value':
case 'checked':
case 'selected': {
// attribute is used as 'default' value
_attributes[key] = val
// and property as instance value
_htmlProps[key] = val
break
}
default: {
_attributes[key] = val
}
}
// no maginc HTML attribute mapping for Components, only plain properties
} else {
_props[key] = val
}
}
}
const el = createElement(type, _props)
if (_ref) {
el.ref(_ref)
}
if (_class) {
el.addClass(_class)
}
if (_styles) {
el.css(_styles)
}
el.attr(_attributes)
el.htmlProp(_htmlProps)
for (const [eventName, handler] of _eventListeners) {
el.on(eventName, handler)
}
if (children.length > 0) {
el.append(flatten(children))
}
return el
}
_render (comp, oldProps, oldState, options = {}) {
let consoleGroup = null
if (substanceGlobals.VERBOSE_RENDERING) {
if (!comp.el) {
consoleGroup = `RenderingEngine: initial render of ${getClassName(comp)}`
} else {
if (options.adopt) {
consoleGroup = `RenderingEngine: adopting DOM with ${getClassName(comp)}`
} else {
consoleGroup = `RenderingEngine: update of ${getClassName(comp)}`
}
}
console.group(consoleGroup)
console.time('rendering (total)')
}
let vel = _createWrappingVirtualComponent(comp)
const state = this._createState()
if (oldProps) {
state.set(OLDPROPS, vel, oldProps)
}
if (oldState) {
state.set(OLDSTATE, vel, oldState)
}
try {
this._state = state
if (substanceGlobals.VERBOSE_RENDERING) {
console.time('capturing')
}
let captured = false
// capture: this calls the render() method of components, creating a virtual DOM
try {
_capture(state, vel, TOP_LEVEL_ELEMENT)
captured = true
} finally {
if (substanceGlobals.VERBOSE_RENDERING) {
console.timeEnd('capturing')
}
}
if (captured) {
if (options.adopt) {
if (substanceGlobals.VERBOSE_RENDERING) {
console.time('adopting')
}
try {
// NOTE: if root is forwarding then use the forwarded child
// instead. The DOM element will be propagated upwards.
vel = _getForwardedEl(vel)
_adopt(state, vel, comp.el)
} finally {
if (substanceGlobals.VERBOSE_RENDERING) {
console.timeEnd('adopting')
}
}
} else {
if (substanceGlobals.VERBOSE_RENDERING) {
console.time('updating')
}
try {
_update(state, vel)
_triggerDidUpdate(state, vel)
} finally {
if (substanceGlobals.VERBOSE_RENDERING) {
console.timeEnd('updating')
}
}
}
}
} finally {
if (substanceGlobals.VERBOSE_RENDERING) {
console.timeEnd('rendering (total)')
console.groupEnd(consoleGroup)
}
state.dispose()
this._state = null
}
}
// this is used together with the incremental Component API
// TODO: we could try to generalize this to allow partial rerenderings
// e.g. a component has a method to rerender just one element, which is then
// applied to update an element
_renderChild (comp, vel) {
// HACK: to make this work with the rest of the implementation
// we ingest a fake parent
const state = this._createState()
vel.parent = { _comp: comp, _isFake: true }
try {
this._state = state
_capture(state, vel)
_update(state, vel)
return vel._comp
} finally {
state.dispose()
}
}
_createState () {
return new RenderingState(this.componentFactory, this.elementFactory)
}
static createContext (comp) {
const vel = _createWrappingVirtualComponent(comp)
return new VirtualElement.Context(vel)
}
}
function _getRenderingContext () {
let renderingContext = substanceGlobals.__rendering_context__
if (!renderingContext) {
renderingContext = new VirtualElement.Context()
}
return renderingContext
}
function _setRenderingContext (renderingContext) {
substanceGlobals.__rendering_context__ = renderingContext
}
// calling comp.render() and capturing recursively
function _capture (state, vel, mode) {
if (state.is(CAPTURED, vel)) {
return vel
}
// a captured VirtualElement has a component instance attached
let comp = vel._comp
if (!comp) {
comp = _create(state, vel)
state.set(NEW, vel)
}
if (vel._isVirtualComponent) {
let needRerender
// NOTE: forceCapture is used for the first entrance
// from this.render(comp) where we want to fource capturing
// as it has already been cleared that a rerender is necessary
if (mode === TOP_LEVEL_ELEMENT) {
needRerender = true
// top-level comp and virtual component are linked per se
_assert(vel._comp === comp, 'top-level element and component should be linked already')
state.set(MAPPED, vel)
state.set(MAPPED, comp)
state.set(LINKED, vel)
state.set(LINKED, comp)
const compData = _getInternalComponentData(comp)
vel.elementProps = compData.elementProps
} else {
// NOTE: don't ask shouldRerender if no element is there yet
needRerender = !comp.el || comp.shouldRerender(vel.props, comp.state)
// Note: in case of VirtualComponents there are typically two actors setting element properties:
// the component instance itself, and the owner, such as in
// `$$(Foo).addClass('se-foo')`
// To be able to retain the element properties set by the parent, we have to bring them out of the way
// before capturing the component
vel.elementProps = vel._copy()
vel._clear()
state.set(OLDPROPS, vel, comp.props)
state.set(OLDSTATE, vel, comp.state)
// updates prop triggering willReceiveProps
comp._setProps(vel.props)
if (!state.is(NEW, vel)) {
state.set(UPDATED, vel)
}
}
if (needRerender) {
const context = new VirtualElement.Context(vel)
let content
try {
_setRenderingContext(context)
content = comp.render(context.$$)
} finally {
_setRenderingContext(null)
}
if (!content) {
throw new Error('Component.render() returned nil.')
} else if (content._isVirtualComponent) {
// allowing for forwarding components
// content needs to have a parent for creating components
vel._forwardedEl = content
vel._isForwarding = true
content._isForwarded = true
content.parent = vel
vel.children = [content]
} else if (content._isVirtualHTMLElement) {
// merge the content into the VirtualComponent instance
vel.tagName = content.tagName
vel._merge(content)
if (content.hasInnerHTML()) {
vel._innerHTMLString = content._innerHTMLString
vel.children = []
} else {
vel.children = content.children
// adopting the children
vel.children.forEach(child => {
child.parent = vel
})
}
} else {
throw new Error('render() must return a plain element or a Component')
}
// retain the rendering context
vel._context = content._context
// augmenting the element properties with those given by the owner
// such as in $$(Foo, { child: $$(Bar).addClass('') })
if (vel.elementProps) {
vel._merge(vel.elementProps)
// augment a forwarded virtual component with the accumlated element properties
// (works also for injected, forwarding components
if (vel._isForwarding) {
vel._forwardedEl._merge(vel)
}
}
// TODO: document what this is used for
if (!state.is(NEW, vel) && comp.isMounted()) {
state.set(UPDATED, vel)
}
// ATTENTION: before capturing we need to link VirtualComponents with
// existing Components so that `render()` can be called on the
// correct instances.
_forEachComponent(state, comp, vel, _linkComponent)
// ATTENTION: without DEBUG_RENDERING enabled the content is captured
// outside of the `render()` call stack i.e. `render()` has finished
// already and provided a virtual element. Children component are
// rendered as part of this recursion, i.e. in the stack trace there
// will be `RenderingEngine._capture()` only
if (vel._forwardedEl) {
_capture(state, vel._forwardedEl)
} else {
for (const child of vel.children) {
_capture(state, child)
}
}
_forEachComponent(state, comp, vel, _propagateLinking)
} else {
// SKIPPED are those components who have returned `shouldRerender() = false`
state.set(SKIPPED, vel)
}
} else if (vel._isVirtualHTMLElement) {
for (const child of vel.children) {
_capture(state, child)
}
}
state.set(CAPTURED, vel)
return vel
}
// called to initialize a captured component, i.e. creating a Component instance
// from a VirtualElement
function _create (state, vel) {
let comp = vel._comp
_assert(!comp, 'Component instance should not exist when this method is used.')
let parent = vel.parent._comp
// making sure the parent components have been instantiated
if (!parent) {
parent = _create(state, vel.parent)
}
// TODO: probably we should do something with forwarded/forwarding components here?
if (vel._isVirtualComponent) {
_assert(parent, 'A Component should have a parent.')
comp = state.componentFactory.createComponent(vel.ComponentClass, parent, vel.props)
// HACK: making sure that we have the right props
// TODO: instead of HACK add an assertion, and make otherwise sure that vel.props is set correctly
vel.props = comp.props
if (vel._forwardedEl) {
const forwardedEl = vel._forwardedEl
const forwardedComp = state.componentFactory.createComponent(forwardedEl.ComponentClass, comp, forwardedEl.props)
// HACK same as before
forwardedEl.props = forwardedComp.props
comp._forwardedComp = forwardedComp
}
} else if (vel._isVirtualHTMLElement) {
comp = state.componentFactory.createElementComponent(parent, vel)
} else if (vel._isVirtualTextNode) {
comp = state.componentFactory.createTextNodeComponent(parent, vel)
}
if (vel._ref) {
comp._ref = vel._ref
}
if (vel._owner) {
comp._owner = vel._owner._comp
}
vel._comp = comp
return comp
}
/*
Prepares a new virtual component by comparing it with the old version.
It sets the _comp references in the new version where its ancestors
can be mapped to corresponding virtual components in the old version.
*/
function _forEachComponent (state, comp, vc, hook) {
_assert(vc._isVirtualComponent, 'this method is intended for VirtualComponents only')
if (!vc.__components__) {
const context = vc._context
_assert(context, 'there should be a capturing context on the VirtualComponent')
// refs are those ref'd using $$().ref()
const newRefs = context.refs
// foreignRefs are refs of those components which are passed via props
const newForeignRefs = context.foreignRefs
// all other components which are not ref'd stored via a derived key based on trace
if (!context.internalRefs) {
context.internalRefs = _extractInternalRefs(context, vc)
}
const newInternalRefs = context.internalRefs
const entries = []
const compData = _getInternalComponentData(comp)
const oldRefs = compData.refs
const oldForeignRefs = compData.foreignRefs
// TODO: make sure that this is always initialized properly
const oldInternalRefs = compData.internalRefs || new Map()
const _addEntries = (_newRefs, _oldRefs) => {
for (const [ref, vc] of _newRefs) {
const oldVc = _oldRefs.get(ref)
let comp
if (oldVc) {
comp = oldVc._comp
}
entries.push({ vc, comp })
}
}
if (newRefs.size > 0) _addEntries(newRefs, oldRefs)
if (newForeignRefs.size > 0) _addEntries(newForeignRefs, oldForeignRefs)
if (newInternalRefs.size > 0) _addEntries(newInternalRefs, oldInternalRefs)
vc.__components__ = entries
}
if (vc.__components__.length > 0) {
for (const entry of vc.__components__) {
hook(state, entry.comp, entry.vc)
}
}
}
function _linkComponent (state, comp, vc) {
// NOTE: comp is undefined if there was no corresponding ref in the previous rendering
if (!comp) {
_reject(state, comp, vc)
return
}
if (_isMapped(state, comp, vc)) return
if (_isLinked(state, comp, vc)) return
if (_isOfSameType(comp, vc)) {
_link(state, comp, vc)
} else {
_reject(state, comp, vc)
}
}
function _link (state, comp, vc) {
vc._comp = comp
state.set(MAPPED, vc)
state.set(MAPPED, comp)
state.set(LINKED, vc)
state.set(LINKED, comp)
}
function _reject (state, comp, vc) {
vc._comp = null
state.set(MAPPED, vc)
if (comp) state.set(MAPPED, comp)
}
function _isMapped (state, comp, vc) {
const vcIsMapped = state.is(MAPPED, vc)
const compIsMapped = state.is(MAPPED, comp)
if (vcIsMapped || compIsMapped) {
return true
}
return false
}
function _isLinked (state, comp, vc) {
const compIsLinked = state.is(LINKED, comp)
const vcIsLinked = state.is(LINKED, vc)
if (vc._comp === comp) {
if (!vcIsLinked) {
console.error('FIXME: comp is linked, but not virtual component')
state.set(LINKED, vc)
}
if (!compIsLinked) {
console.error('FIXME: virtual comp is linked, but not component')
state.set(LINKED, vc)
}
return true
}
return false
}
/*
This tries to map the virtual component to existing component instances
by looking at the old and new refs, making sure that the element type is
compatible.
*/
function _propagateLinking (state, comp, vel, stopIfMapped) {
// NOTE: comp is undefined if there was no corresponding ref in the previous rendering
// or when bubbling up to the root component
if (!comp) {
return false
}
// stopping condition
if (stopIfMapped && _isMapped(state, comp, vel)) {
return _isLinked(state, comp, vel)
}
// try to link VirtualHTMLElements and VirtualTextElements
// allowing to retain DOM elements
if (!vel._isVirtualComponent) {
if (!_isOfSameType(comp, vel)) {
_reject(state, comp, vel)
// stop propagation here
return false
} else {
_link(state, comp, vel)
}
}
// Now we try to map all ancestors. If not possible, then we assume that the component has been relocated
let canLinkParent = false
let parent = comp.getParent()
if (vel.parent) {
canLinkParent = _propagateLinking(state, parent, vel.parent, true)
// to be able to support implicit retaining of elements
// we need to propagate mapping through the 'preliminary' parent chain
// i.e. not taking the real parents as rendered, but the Components into which
// we have passed children (via vel.append() or vel.outlet().append())
} else if (vel._preliminaryParent) {
while (parent && parent._isElementComponent) {
parent = parent.getParent()
}
canLinkParent = _propagateLinking(state, parent, vel._preliminaryParent, true)
}
// VirtualComponent that have parents that could not be mapped must have been
// relocated, i.e. attached to a different parent
// TODO: discuss if we really want to allow this.
// Relocation is an edge case, in most cases not desired, and thus if happened
// more likely to be a problem.
if (vel._isVirtualComponent && !canLinkParent) {
if (substanceGlobals.VERBOSE_RENDERING) {
console.info('Component has been relocated: ' + getClassName(comp))
}
state.set(RELOCATED, vel)
state.set(RELOCATED, comp)
}
return canLinkParent
}
function _isOfSameType (comp, vc) {
if (vc._isVirtualComponent) {
const ComponentClass = _getComponentClass(vc)
return (comp._isComponent && comp.constructor === ComponentClass)
} else {
return (
(comp._isElementComponent && vc._isVirtualHTMLElement) ||
(comp._isTextNodeComponent && vc._isVirtualTextNode)
)
}
}
function _getComponentClass (vc) {
const ComponentClass = vc.ComponentClass
if (ComponentClass._isFunctionComponent) {
return ComponentClass._ComponentClass
}
return ComponentClass
}
// Update a DOM element by applying changes derived from a given virtual element
function _update (state, vel) {
// NOTE: this method might look a bit monstrous because of the rather complex
// branching structure. However, we want to avoid extra recursion or separation
// into functions for sake of shorter stack-traces when debugging
if (state.is(SKIPPED, vel)) return
// console.log('... rendering', vel._ref)
const comp = vel._comp
// TODO: find out if this is still needed
if (!comp) {
_capture(state, vel)
}
_assert(comp && comp._isComponent, 'A captured VirtualElement must have a component instance attached.')
// special handling of forwarding elements which don't have their own element
// but are delegating to their child
if (vel._isForwarding) {
_update(state, vel._forwardedEl)
} else {
// render the element
if (!comp.el) {
comp.el = _createDOMElement(state, vel)
} else {
const el = comp.el
_assert(el, "Component's element should exist at this point.")
_updateDOMElement(el, vel)
}
// structural updates are necessary only for non-forwarding Components and HTML elements without innerHTML
if ((vel._isVirtualComponent || vel._isVirtualHTMLElement) && !vel.hasInnerHTML()) {
const newChildren = vel.children
const oldChildren = _getChildren(state, comp)
// TODO: it might be easier to understand to separate DOM analysis, i.e.
// what to do with the DOM, from the actual DOM manipulation.
// The former could be described as a set of DOM operations, which would then
// interpreted by the latter
let pos1 = 0; let pos2 = 0
while (pos1 < oldChildren.length || pos2 < newChildren.length) {
let oldComp
// skip detached components
// Note: components get detached when preserved nodes
// are found in a swapped order. Then the only way is
// to detach one of them from the DOM, and reinsert it later at the new
// position
do {
oldComp = oldChildren[pos1++]
} while (oldComp && (state.is(DETACHED, oldComp)))
const newVel = newChildren[pos2++]
// remove remaining old ones if no new one is left
if (oldComp && !newVel) {
while (oldComp) {
_removeChild(state, comp, oldComp)
oldComp = oldChildren[pos1++]
}
break
}
// reuse TextNodes to avoid unnecesary DOM manipulations
if (oldComp && oldComp.el.isTextNode() &&
newVel && newVel._isVirtualTextNode &&
oldComp.el.textContent === newVel.text) {
continue
}
// ATTENTION: here we are linking two HTML elements opportunistically on the fly
// Note, that !state.is(MAPPED) means that both elements do not contain
// any refs or components, and are thus save to be reused
// TODO: we should find out if this is really something we want to do
// or stick to primitive rendering for sake of performance
if (oldComp && oldComp._isElementComponent &&
newVel._isVirtualHTMLElement &&
!state.is(MAPPED, oldComp) && !state.is(MAPPED, newVel) &&
oldComp.tagName === newVel.tagName) {
// linking
newVel._comp = oldComp
state.set(LINKED, newVel)
state.set(LINKED, oldComp)
_update(state, newVel)
continue
}
// update virtual component recursively
if (!state.is(RENDERED, newVel)) {
// HACK: fixing wrong parent links
// TODO: identify when this happens and find a better solution
// Up to now I found out:
// - see Component.test@'Elements with different refs have different component instances'
// -> in this case I think the elements do not get mapped because there are no (matching) Components
// -> which leads to pre-created comps during capture phase
if (newVel._comp && newVel._comp.parent !== comp) {
if (substanceGlobals.VERBOSE_RENDERING) {
console.warn('Found captured child component with wrong parent link. Fixing up.')
}
newVel._comp.parent = comp
}
_update(state, newVel)
}
const newComp = newVel._comp
// nothing more to do if components are equal, i.e. component and virtual component have been linked during capturing
if (newComp === oldComp) {
continue
}
_assert(newComp, 'Component instance should now be available.')
// update the parent for relocated components
// ATTENTION: relocating a component does not update its context
if (state.is(RELOCATED, newComp)) {
newComp._setParent(comp)
}
_assert(comp === newComp.parent, 'Link to parent component should be correct.')
// append remaining new ones if no old one is left
if (newVel && !oldComp) {
_appendChild(state, comp, newComp)
continue
}
// Differential update
if (state.is(LINKED, newVel)) {
if (state.is(LINKED, oldComp)) {
// the order of elements with ref has changed
state.set(DETACHED, oldComp)
_removeChild(state, comp, oldComp)
pos2--
// the old one could not be mapped, thus can be removed
} else {
_removeChild(state, comp, oldComp)
pos2--
}
} else if (state.is(LINKED, oldComp)) {
_insertChildBefore(state, comp, newComp, oldComp)
pos1--
} else {
// both elements are not mapped
// TODO: we could try to reuse components if they are of same type
// However, this needs a more involved mapping strategy, and/or a change
// in the order of this iteration. At this point it is already too late
// because the recursive update has already been done, not reusing the existing elements
_replaceChild(state, comp, oldComp, newComp)
}
}
}
}
if (vel._isVirtualComponent) {
_storeInternalData(comp, vel)
// using the element of the forwarded component as element for this component
if (vel._forwardedEl) {
const forwardedComp = vel._forwardedEl._comp
// TODO: is this really the correct time to call didMount? shouldn't this
// be called when processed by the parent?
// TODO: this will not work with multiple forwarded components
if (!comp.el) {
comp.el = forwardedComp.el
}
// Dealing with situations where the forwarded element/component has been replaced
// e.g. switching between editor and reader in the same forwarding component.
// Only the actual parent of the forwarded component should do this, not any
// other forwarding component in the same forwarding chain.
// TODO: this fix-up seems strange. IMO we should change
// the way how forwarding components are implemented
// leading to a more explicit solution which also should work better together
// with the rest of the update implementation
if (!vel._forwardedEl._isForwarding) {
const oldForwardedComp = comp.el._comp
if (oldForwardedComp !== forwardedComp) {
oldForwardedComp.triggerDispose()
comp.el.parentNode.replaceChild(comp.el, forwardedComp.el)
comp.el = forwardedComp.el
forwardedComp.triggerDidMount()
}
}
}
}
state.set(RENDERED, vel)
state.set(RENDERED, comp)
}
// remove all elements from the DOM which are not linked to a component
// or which we know have been relocated
// ATTENTION: removing the elements of relocated components
// in advance, then the algorithm later becomes easier only considering
// add and remove.
function _getChildren (state, comp) {
const _childNodes = comp.el.getChildNodes()
const children = _childNodes.map(child => {
let childComp = child._comp
// NOTE: don't know why, but sometimes it happens that there appear elements that are not rendered via Component.js
if (!childComp) {
comp.el.removeChild(child)
return null
}
// EXPERIMENTAL: trying to get forwarding components right.
// the problem is that on the DOMElement level, forwarding components are not
// 'visible', as they do not have an own element.
// Here we are taking the owner of an element when it isForwarded
// bubbling up the parent hierarchy.
if (childComp._isForwarded()) {
childComp = _findForwardingComponent(comp, childComp)
}
// remove orphaned nodes and relocated components
if (!childComp || state.is(RELOCATED, childComp)) {
comp.el.removeChild(child)
return null
} else {
return childComp
}
}).filter(Boolean)
return children
}
function _adopt (state, vel, el) {
const comp = vel._comp
if ((vel._isVirtualComponent || vel._isVirtualHTMLElement) && !el.isElementNode()) {
throw new Error('Provided DOM element is not compatible.')
}
comp.el = el
el._comp = comp
_updateDOMElement(el, vel)
_propagateForwardedEl(vel, el)
if ((vel._isVirtualComponent || vel._isVirtualHTMLElement)) {
const existingChildNodes = el.childNodes.slice()
const virtualChildNodes = vel.children
let pos1 = 0; let pos2 = 0
while (pos1 < existingChildNodes.length || pos2 < virtualChildNodes.length) {
let child1 = existingChildNodes[pos1]
let child2 = virtualChildNodes[pos2]
// remove all remaining DOM nodes
if (!child2) {
while (child1) {
child1.remove()
pos1++
child1 = existingChildNodes[pos1]
}
break
}
if (!child1) {
while (child2) {
el.appendChild(_createEl(state, child2))
if (child2._isVirtualComponent) {
_storeInternalData(child2._comp, child2)
}
pos2++
child2 = virtualChildNodes[pos2]
}
break
}
// continue with the forwarded element
child2 = _getForwardedEl(child2)
// remove incompatible DOM elements
if (
(child1.isElementNode() && (child2._isVirtualHTMLElement || child2._isVirtualComponent)) ||
(child1.isTextNode() && child2._isVirtualTextNode)
) {
_adopt(state, child2, child1)
pos1++
pos2++
} else {
child1.remove()
pos1++
continue
}
}
if (vel._isVirtualComponent) {
_storeInternalData(comp, vel)
}
}
}
function _createEl (state, vel) {
const el = _createDOMElement(state, vel)
vel._comp.el = el
el._comp = vel._comp
_propagateForwardedEl(vel, el)
if ((vel._isVirtualComponent || vel._isVirtualHTMLElement)) {
vel.children.forEach(vc => {
vc = _getForwardedEl(vc)
el.appendChild(_createEl(state, vc))
})
}
return el
}
function _getForwardedEl (vel) {
// Note: if the root component is forwarding
// we have to use the forwarded element instead
// _propagateForwardedEl() will latern propagate the element up-tree
while (vel._isForwarding) {
vel = vel._forwardedEl
}
return vel
}
function _propagateForwardedEl (vel, el) {
if (vel._isForwarded) {
let parent = vel.parent
while (parent && parent._isForwarding) {
parent._comp.el = el
_storeInternalData(parent._comp, parent)
parent = parent.parent
}
}
}
function _getInternalComponentData (comp) {
if (!comp.__internal__) {
comp.__internal__ = new InternalComponentData()
}
return comp.__internal__
}
function _storeInternalData (comp, vc) {
const context = vc._context
const compData = _getInternalComponentData(comp)
compData.elementProps = vc.elementProps
compData.refs = context.refs
compData.foreignRefs = context.foreignRefs
compData.internalRefs = context.internalRefs
// creating a plain object with refs to real component instances
comp.refs = Array.from(context.refs).reduce((refs, [key, vc]) => {
// ATTENTION: in case that a referenced component has not been used,
// i.e. actually appended to an element, the virtual component will not be rendered
// thus does not have component instance attached
const comp = vc._comp
if (comp) {
refs[key] = vc._comp
} else {
console.warn(`Warning: component with reference '${key}' has not been used`)
}
return refs
}, {})
}
function _extractInternalRefs (context, root) {
const idCounts = new Map()
const refs = new Map()
for (const vc of context.components) {
// TODO: also skip those components which are not appended to the current comp
if (vc._ref) continue
let ref = _getVirtualComponentTrace(vc, root)
// disambiguate generated refs by appending '@<count>'
if (idCounts.has(ref)) {
const count = idCounts.get(ref) + 1
idCounts.set(ref, count)
ref = ref + '@' + count
} else {
idCounts.set(ref, 1)
}
refs.set(ref, vc)
}
return refs
}
function _getVirtualComponentTrace (vc, root) {
const frags = [getClassName(vc.ComponentClass)]
if (!vc._isForwarded) {
let parent = vc.getParent()
while (parent) {
if (parent === root) break
// ATTENTION: incremental render uses a fake parent
if (parent._isFake) break
// ATTENTION if the vc has been appended then its ancestors are all virtual HTML elements
_assert(parent._isVirtualHTMLElement, 'parent should be VirtualHTMLElement')
frags.unshift(parent.tagName)
parent = parent.parent
}
}
return frags.join('/')
}
function _triggerDidUpdate (state, vel) {
if (vel._isVirtualComponent) {
if (!state.is(SKIPPED, vel)) {
vel.children.forEach(_triggerDidUpdate.bind(null, state))
}
if (state.is(UPDATED, vel)) {
vel._comp.didUpdate(state.get(OLDPROPS, vel), state.get(OLDSTATE, vel))
}
} else if (vel._isVirtualHTMLElement) {
vel.children.forEach(_triggerDidUpdate.bind(null, state))
}
}
function _appendChild (state, parent, child) {
parent.el.appendChild(child.el)
_triggerDidMount(state, parent, child)
}
function _replaceChild (state, parent, oldChild, newChild) {
parent.el.replaceChild(oldChild.el, newChild.el)
if (!state.is(DETACHED, oldChild)) {
oldChild.triggerDispose()
}
_triggerDidMount(state, parent, newChild)
}
function _insertChildBefore (state, parent, child, before) {
parent.el.insertBefore(child.el, before.el)
_triggerDidMount(state, parent, child)
}
function _removeChild (state, parent, child) {
parent.el.removeChild(child.el)
if (!state.is(DETACHED, child)) {
child.triggerDispose()
}
}
function _triggerDidMount (state, parent, child) {
if (!state.is(DETACHED, child) &&
parent.isMounted() && !child.isMounted()) {
child.triggerDidMount(true)
}
}
function _createDOMElement (state, vel) {
let el
if (vel._isVirtualTextNode) {
el = state.elementFactory.createTextNode(vel.text)
} else {
el = state.elementFactory.createElement(vel.tagName)
}
if (vel._comp) {
el._comp = vel._comp
}
_updateDOMElement(el, vel)
return el
}
function _updateDOMElement (el, vel) {
// special handling for text nodes
if (vel._isVirtualTextNode) {
if (el.textContent !== vel.text) {
el.setTextContent(vel.text)
}
return
}
const tagName = el.getTagName()
if (vel.tagName.toLowerCase() !== tagName) {
el.setTagName(vel.tagName)
}
_updateHash({
oldHash: el.getAttributes(),
newHash: vel.getAttributes(),
update: function (key, val) {
el.setAttribute(key, val)
},
remove: function (key) {
el.removeAttribute(key)
}
})
_updateHash({
oldHash: el.htmlProps,
newHash: vel.htmlProps,
update: function (key, val) {
el.setProperty(key, val)
},
remove: function (key) {
el.removeProperty(key)
}
})
_updateListeners({
el,
oldListeners: el.getEventListeners(),
newListeners: vel.getEventListeners()
})
// special treatment of HTML elements having custom innerHTML
if (vel.hasInnerHTML()) {
if (!el._hasInnerHTML) {
el.empty()
el.setInnerHTML(vel.getInnerHTML())
} else {
const oldInnerHTML = el.getInnerHTML()
const newInnerHTML = vel.getInnerHTML()
if (oldInnerHTML !== newInnerHTML) {
el.setInnerHTML(newInnerHTML)
}
}
el._hasInnerHTML = true
}
}
function _hashGet (hash, key) {
if (isFunction(hash.get)) {
return hash.get(key)
} else {
return hash[key]
}
}
function _updateHash ({ newHash, oldHash, update, remove }) {
if (!newHash && !oldHash) return
// TODO: this could be improved with a simpler impl that removes all old
if (!newHash) {
newHash = new Map()
}
// TODO: this could be improved with a simpler impl that adds all new
if (!oldHash) {
oldHash = new Map()
}
const updatedKeys = {}
// FIXME: this is not working as expected in browser
// i.e. _hashGet does not take the 'AttrbutesMap' thing into account
// and provides 'undefined' for the most cases
for (const key of newHash.keys()) {
const oldVal = _hashGet(oldHash, key)
const newVal = _hashGet(newHash, key)
updatedKeys[key] = true
if (oldVal !== newVal) {
update(key, newVal)
}
}
// TODO: try to consolidate this.
// we have a horrible mixture of Objects and Maps here
// want to move to the Map based impl
if (isFunction(oldHash.keys)) {
const keys = Array.from(oldHash.keys())
keys.forEach((key) => {
if (!updatedKeys[key]) {
remove(key)
}
})
} else {
for (const key in oldHash) {
if (hasOwnProperty(oldHash, key) && !updatedKeys[key]) {
remove(key)
}
}
}
}
function _updateListeners (args) {
const el = args.el
// NOTE: considering the low number of listeners
// it is quicker to just remove all
// and add again instead of computing the minimal update
const newListeners = args.newListeners || []
el.removeAllEventListeners()
for (let i = 0; i < newListeners.length; i++) {
el.addEventListener(newListeners[i])
}
}
function _findForwardingComponent (comp, forwarded) {
let current = forwarded.getParent()
while (current) {
const parent = current.getParent()
if (parent === comp) {
return current
}
current = parent
}
}
function _createWrappingVirtualComponent (comp) {
const vel = new VirtualElement.Component(comp.constructor)
vel._comp = comp
return vel
}
const CAPTURED = Symbol('CAPTURED')
const DETACHED = Symbol('DETACHED')
const LINKED = Symbol('LINKED')
const MAPPED = Symbol('MAPPED')
const NEW = Symbol('NEW')
const OLDPROPS = Symbol('OLDPROPS')
const OLDSTATE = Symbol('OLDSTATE')
// 'relocated' means a node with ref
// has been attached to a new parent node
const RELOCATED = Symbol('RELOCATED')
const RENDERED = Symbol('RENDERED')
const SKIPPED = Symbol('SKIPPED')
const UPDATED = Symbol('UPDATED')
class RenderingState {
constructor (componentFactory, elementFactory) {
this.componentFactory = componentFactory
this.elementFactory = elementFactory
this._states = new Map()
this.contexts = []
}
dispose () {
this.contexts = []
}
set (key, obj, val = true) {
let info = this._states.get(obj)
if (!info) {
info = new Map()
this._states.set(obj, info)
}
info.set(key, val)
}
get (key, obj) {
const info = this._states.get(obj)
if (info) {
return info.get(key)
}
}
is (key, obj) {
return Boolean(this.get(key, obj))
}
pushContext (context) {
this.contexts.push(context)
}
popContext () {
return this.contexts.pop()
}
getCurrentContext () {
return this.contexts[this.contexts.length - 1]
}
}
function _assert (cond, msg) {
if (!cond) {
if (substanceGlobals.ASSERTS) {
throw new Error('Assertion failed: ' + msg)
}
}
}
class InternalComponentData {
constructor () {
this.refs = new Map()
this.foreignRefs = new Map()
this.internalRefs = new Map()
this.elementProps = null
}
}
// exposing internal API for testing
RenderingEngine._INTERNAL_API = {
_capture,
_wrap: _createWrappingVirtualComponent,
_update,
CAPTURED,
DETACHED,
LINKED,
MAPPED,
NEW,
RELOCATED,
RENDERED,
SKIPPED,
TOP_LEVEL_ELEMENT,
UPDATED
}