UNPKG

catberry

Version:

Catberry is an isomorphic framework for building universal front-end apps using components, Flux architecture and progressive rendering.

1,268 lines (1,124 loc) 37.2 kB
'use strict'; const morphdom = require('morphdom'); const uuid = require('uuid'); const errorHelper = require('../lib/helpers/errorHelper'); const moduleHelper = require('../lib/helpers/moduleHelper'); const hrTimeHelper = require('../lib/helpers/hrTimeHelper'); const DocumentRendererBase = require('../lib/base/DocumentRendererBase'); const SPECIAL_IDS = { $$head: '$$head', $$document: '$$document' }; const TAG_NAMES = { HEAD: 'HEAD', STYLE: 'STYLE', SCRIPT: 'SCRIPT', LINK: 'LINK' }; // http://www.w3.org/TR/2015/WD-uievents-20150319/#event-types-list const NON_BUBBLING_EVENTS = { abort: true, blur: true, error: true, focus: true, load: true, mouseenter: true, mouseleave: true, resize: true, unload: true }; class DocumentRenderer extends DocumentRendererBase { /** * Creates a new instance of the document renderer. * @param {ServiceLocator} locator Locator for resolving dependencies. */ constructor(locator) { super(locator); /** * Current set of component instances by unique keys. * @type {Object} * @private */ this._componentInstances = Object.create(null); /** * Current set of component elements by unique keys. * @type {Object} * @private */ this._componentElements = Object.create(null); /** * Current set of component bindings by unique keys. * @type {Object} * @private */ this._componentBindings = Object.create(null); /** * Current set of changed stores. * @type {Object} * @private */ this._currentChangedStores = Object.create(null); /** * Current browser's window. */ this._window = locator.resolve('window'); /** * Current application config. * @type {Object} * @private */ this._config = locator.resolve('config'); /** * Current store dispatcher. * @type {StoreDispatcher} * @protected */ this._storeDispatcher = locator.resolve('storeDispatcher'); /** * Current promise for rendered page. * @type {Promise} * @private */ this._renderedPromise = null; /** * Current state of updating components. * @type {boolean} * @private */ this._isUpdating = false; /** * Current awaiting routing. * @type {{state: Object, routingContext: Object}} * @private */ this._awaitingRouting = null; /** * Current routing context. * @type {Object} * @private */ this._currentRoutingContext = null; this._eventBus.on('storeChanged', storeName => { this._currentChangedStores[storeName] = true; if (this._isStateChanging) { return; } this._updateStoreComponents(); }); } /** * Sets the initial state of the application. * @param {Object} state New state of the application. * @param {Object} routingContext Routing context. * @returns {Promise} Promise for nothing. */ initWithState(state, routingContext) { return this._getPromiseForReadyState() .then(() => { this._currentRoutingContext = routingContext; return this._storeDispatcher.setState(state, routingContext); }) .then(() => { const components = this._componentLoader.getComponentsByNames(); const documentElement = this._window.document.documentElement; const action = element => this._initializeComponent(element, components); return this._traverseComponents([documentElement], components, action); }) .then(() => this._eventBus.emit( 'documentRendered', this._currentRoutingContext )); } /** * Renders a new state of the application. * @param {Object} state New state of the application. * @param {Object} routingContext Routing context. * @returns {Promise} Promise for nothing. */ render(state, routingContext) { this._awaitingRouting = { state, routingContext }; if (this._isStateChanging) { return this._renderedPromise; } // we should set this flag to avoid "storeChanged" // event handling for now this._isStateChanging = true; this._renderedPromise = this._getPromiseForReadyState() // and then we update all components of these stores in a batch. .then(() => this._updateStoreComponents()) .catch(reason => this._eventBus.emit('error', reason)) .then(() => { this._isStateChanging = false; }); return this._renderedPromise; } /** * Renders a component into the HTML element. * @param {Element} element HTML element of the component. * @param {Object?} renderingContext Rendering context for group rendering. */ renderComponent(element, renderingContext) { return this._getPromiseForReadyState() .then(() => { const id = this._getId(element); const componentName = moduleHelper.getOriginalComponentName(element.tagName); if (!renderingContext) { renderingContext = this._createRenderingContext([]); renderingContext.rootIds[id] = true; } const hadChildren = (element.children.length > 0); const component = renderingContext.components[componentName]; if (!component) { return null; } renderingContext.renderedIds[id] = true; let instance = this._componentInstances[id]; if (!instance) { component.constructor.prototype.$context = this._getComponentContext(component, element); instance = new component.constructor(this._serviceLocator); instance.$context = component.constructor.prototype.$context; this._componentInstances[id] = instance; } const eventArgs = { name: componentName, context: instance.$context }; this._componentElements[id] = element; const startTime = hrTimeHelper.get(); this._eventBus.emit('componentRender', eventArgs); return Promise.resolve() .then(() => { // we need unbind the whole hierarchy only at // the beginning and not for new elements if (!(id in renderingContext.rootIds) || !hadChildren) { return []; } return this._unbindAll(element, renderingContext); }) .catch(reason => this._eventBus.emit('error', reason)) .then(() => { const renderMethod = moduleHelper.getMethodToInvoke(instance, 'render'); return moduleHelper.getSafePromise(renderMethod); }) .then(dataContext => component.template.render(dataContext)) .catch(reason => this._handleRenderError(element, component, reason)) .then(html => { const isHead = element.tagName === TAG_NAMES.HEAD; if (html === '' && isHead) { return []; } const tmpElement = element.cloneNode(false); tmpElement.innerHTML = html; if (isHead) { this._mergeHead(element, tmpElement); return []; } morphdom(element, tmpElement, { onBeforeElChildrenUpdated: foundElement => foundElement === element || !this._isComponentElement( renderingContext.components, foundElement ) }); const promises = this._findNestedComponents( element, renderingContext.components ) .map(child => this.renderComponent(child, renderingContext)); return Promise.all(promises); }) .then(() => { eventArgs.hrTime = hrTimeHelper.get(startTime); eventArgs.time = hrTimeHelper.toMilliseconds(eventArgs.hrTime); this._eventBus.emit('componentRendered', eventArgs); return this._bindComponent(element); }) .then(() => { // collecting garbage only when // the entire rendering is finished if (!(id in renderingContext.rootIds) || !hadChildren) { return; } this._collectRenderingGarbage(renderingContext); }) .catch(reason => this._eventBus.emit('error', reason)); }); } /** * Gets a component instance by ID. * @param {string} id Component's element ID. * @returns {Object|null} Component instance. */ getComponentById(id) { const element = this._window.document.getElementById(id); return this.getComponentByElement(element); } /** * Does query for a component by the selector. * @param {string} selector Selector for the query. * @param {Object?} parentComponent Parent component object. * @returns {Object} The found component object. */ queryComponentSelector(selector, parentComponent) { const parent = this._isComponentObject(parentComponent) ? parentComponent.$context.element : this._window.document; return this.getComponentByElement(parent.querySelector(selector)); } /** * Does query for all components by the selector. * @param {string} selector Selector for the query. * @param {Object?} parentComponent Parent component object. * @returns {Array} The found component object list. */ queryComponentSelectorAll(selector, parentComponent) { const parent = this._isComponentObject(parentComponent) ? parentComponent.$context.element : this._window.document; return this._mapElementsToComponents(parent.querySelectorAll(selector)); } /** * Gets all components by the tag name. * @param {string} tagName Tag name of the components. * @param {Object?} parentComponent Parent component object. * @returns {Array} The found component object list. */ getComponentsByTagName(tagName, parentComponent) { const parent = this._isComponentObject(parentComponent) ? parentComponent.$context.element : this._window.document; return this._mapElementsToComponents(parent.getElementsByTagName(tagName)); } /** * Gets all components by the class name. * @param {string} className Class name of the components. * @param {Object?} parentComponent Parent component object. * @returns {Array} The found component object list. */ getComponentsByClassName(className, parentComponent) { const parent = this._isComponentObject(parentComponent) ? parentComponent.$context.element : this._window.document; return this._mapElementsToComponents(parent.getElementsByClassName(className)); } /** * Gets component instance by a DOM element. * @param {Element} element Component's Element. * @returns {Object|null} Component instance. */ getComponentByElement(element) { if (!element) { return null; } const id = element[moduleHelper.COMPONENT_ID]; if (!id) { return null; } return this._componentInstances[id] || null; } /** * Checks that every instance of the component has an element on the page and * removes all references to those components which were removed from DOM. * @returns {Promise} Promise for nothing. */ /* eslint max-nested-callbacks: 0 */ collectGarbage() { return this._getPromiseForReadyState() .then(() => { const context = { roots: [], components: this._componentLoader.getComponentsByNames() }; Object.keys(this._componentElements) .forEach(id => { // we should not remove special elements like HEAD if (SPECIAL_IDS.hasOwnProperty(id)) { return; } let current = this._componentElements[id]; while (current !== this._window.document.documentElement) { // the component is situated in a detached DOM subtree if (current.parentElement === null) { context.roots.push(current); break; } // the component is another component's descendant if (this._isComponentElement(context.components, current.parentElement)) { break; } current = current.parentElement; } }); return this._removeDetachedComponents(context); }); } /** * Creates and renders a component element. * @param {string} tagName Name of the HTML tag. * @param {Object?} attributes Element attributes. * @returns {Promise<Element>} Promise for HTML element with the rendered component. */ createComponent(tagName, attributes) { if (typeof (tagName) !== 'string') { return Promise.reject( new Error('The tag name must be a string') ); } attributes = attributes || Object.create(null); return this._getPromiseForReadyState() .then(() => { const components = this._componentLoader.getComponentsByNames(); const componentName = moduleHelper.getOriginalComponentName(tagName); if (moduleHelper.isHeadComponent(componentName) || moduleHelper.isDocumentComponent(componentName) || !(componentName in components)) { return Promise.reject(new Error(`Component for tag "${tagName}" not found`)); } const safeTagName = moduleHelper.getTagNameForComponentName(componentName); const element = this._window.document.createElement(safeTagName); Object.keys(attributes) .forEach(attributeName => element.setAttribute(attributeName, attributes[attributeName])); return this.renderComponent(element) .then(() => element); }); } /** * Clears all references to removed components during the rendering process. * @param {Object} renderingContext Context of rendering. * @private */ _collectRenderingGarbage(renderingContext) { Object.keys(renderingContext.unboundIds) .forEach(id => { // this component has been rendered again and we do not need to // remove it. if (id in renderingContext.renderedIds) { return; } this._removeComponentById(id); }); } /** * Removes detached subtrees from the components set. * @param {{roots: Array, components: Object}} context Operation context. * @returns {Promise} Promise for finished removal * @private */ _removeDetachedComponents(context) { if (context.roots.length === 0) { return Promise.resolve(); } const root = context.roots.pop(); return this._traverseComponents([root], context.components, element => this._removeDetachedComponent(element)) .then(() => this._removeDetachedComponents(context)); } /** * Removes detached component. * @param {Element} element Element of the detached component. * @returns {Promise} Promise for the removed component. * @private */ _removeDetachedComponent(element) { const id = this._getId(element); return this._unbindComponent(element) .then(() => this._removeComponentById(id)); } /** * Unbinds all event handlers from the specified component and all it's descendants. * @param {Element} element Component HTML element. * @param {Object} renderingContext Context of rendering. * @returns {Promise} Promise for nothing. * @private */ _unbindAll(element, renderingContext) { const action = innerElement => { const id = this._getId(innerElement); renderingContext.unboundIds[id] = true; return this._unbindComponent(innerElement); }; return this._traverseComponents([element], renderingContext.components, action); } /** * Unbinds all event handlers from the specified component. * @param {Element} element Component HTML element. * @returns {Promise} Promise for nothing. * @private */ _unbindComponent(element) { const id = this._getId(element); const instance = this._componentInstances[id]; if (!instance) { return Promise.resolve(); } if (id in this._componentBindings) { Object.keys(this._componentBindings[id]) .forEach(eventName => { element.removeEventListener( eventName, this._componentBindings[id][eventName].handler, NON_BUBBLING_EVENTS.hasOwnProperty(eventName) ); }); delete this._componentBindings[id]; } const unbindMethod = moduleHelper.getMethodToInvoke(instance, 'unbind'); return moduleHelper.getSafePromise(unbindMethod) .then(() => { this._eventBus.emit('componentUnbound', { element, id: element.id || null }); }) .catch(reason => this._eventBus.emit('error', reason)); } /** * Removes a component from the current list. * @param {string} id Component's ID * @private */ _removeComponentById(id) { delete this._componentElements[id]; delete this._componentInstances[id]; delete this._componentBindings[id]; } /** * Binds all required event handlers to the component. * @param {Element} element Component's HTML element. * @returns {Promise} Promise for nothing. * @private */ _bindComponent(element) { const id = this._getId(element); const instance = this._componentInstances[id]; if (!instance) { return Promise.resolve(); } const bindMethod = moduleHelper.getMethodToInvoke(instance, 'bind'); return moduleHelper.getSafePromise(bindMethod) .then(bindings => { if (!bindings || typeof (bindings) !== 'object') { this._eventBus.emit('componentBound', { element, id: element.id || null }); return; } this._componentBindings[id] = Object.create(null); Object.keys(bindings) .forEach(eventName => { eventName = eventName.toLowerCase(); if (eventName in this._componentBindings[id]) { return; } const selectorHandlers = Object.create(null); Object.keys(bindings[eventName]) .forEach(selector => { const handler = bindings[eventName][selector]; if (typeof (handler) !== 'function') { return; } selectorHandlers[selector] = handler.bind(instance); }); this._componentBindings[id][eventName] = { handler: this._createBindingHandler(element, selectorHandlers), selectorHandlers }; element.addEventListener( eventName, this._componentBindings[id][eventName].handler, NON_BUBBLING_EVENTS.hasOwnProperty(eventName) ); }); this._eventBus.emit('componentBound', { element, id: element.id || null }); }); } /** * Creates a universal event handler for delegated events. * @param {Element} componentRoot Root element of the component. * @param {Object} selectorHandlers Map of event handlers by their CSS selectors. * @returns {Function} Universal event handler for delegated events. * @private */ _createBindingHandler(componentRoot, selectorHandlers) { const selectors = Object.keys(selectorHandlers); return event => { var element = event.target; const dispatchedEvent = createCustomEvent(event, () => element); var targetMatches = this._getMatchesMethod(element); var isHandled = selectors.some(selector => { if (targetMatches(selector)) { selectorHandlers[selector](dispatchedEvent); return true; } return false; }); if (isHandled || !event.bubbles) { return; } // we can't use parentElement here, because // internal SVG elements don't have this property in IE while (element.parentNode && element !== componentRoot) { element = element.parentNode; targetMatches = this._getMatchesMethod(element); isHandled = this._tryDispatchEvent( selectors, targetMatches, selectorHandlers, dispatchedEvent ); if (isHandled) { break; } } }; } /** * Tries to dispatch an event. * @param {Array} selectors The list of supported selectors. * @param {Function} matchPredicate The function to check if selector matches. * @param {Object} handlers The set of handlers for events. * @param {Event} event The DOM event object. * @private */ _tryDispatchEvent(selectors, matchPredicate, handlers, event) { return selectors.some(selector => { if (!matchPredicate(selector)) { return false; } handlers[selector](event); return true; }); } /** * Checks if the element is a component. * @param {Object} components Current components. * @param {Element} element DOM element. * @private */ _isComponentElement(components, element) { if (!moduleHelper.isComponentNode(element)) { return false; } return moduleHelper.getOriginalComponentName(element.nodeName) in components; } /** * Checks if the specified object is a component. * @param {Object} obj The component object. * @returns {boolean} * @private */ _isComponentObject(obj) { return obj && obj.$context && typeof (obj.$context) === 'object' && obj.$context.element instanceof this._window.Element; } /** * Maps found elements to component objects filtering non-component elements. * @param {HTMLCollection|NodeList} elements Elements collection. * @returns {Array} Array of the component objects. * @private */ _mapElementsToComponents(elements) { const results = []; Array.prototype.forEach .call(elements, element => { const component = this.getComponentByElement(element); if (component) { results.push(component); } }); return results; } /** * Does asynchronous traversal through the components hierarchy. * @param {Array} elements Elements to start the search. * @param {Object} components Current set of components. * @param {function} action Action for every component. * @returns {Promise} Promise for the finished traversal. * @private */ _traverseComponents(elements, components, action) { if (elements.length === 0) { return Promise.resolve(); } const root = elements.shift(); elements = elements.concat(this._findNestedComponents(root, components)); return this._traverseComponents(elements, components, action) .then(() => action(root)); } /** * Finds all descendant components of the specified component root. * @param {Element} root Root component's HTML root to begin search with. * @param {Object} components Map of components by their names. * @private */ _findNestedComponents(root, components) { const elements = []; const queue = [root]; // does breadth-first search inside the root element while (queue.length > 0) { const currentChildren = queue.shift().children; if (!currentChildren) { continue; } Array.prototype.forEach.call(currentChildren, currentChild => { // and they should be components if (!this._isComponentElement(components, currentChild)) { queue.push(currentChild); return; } elements.push(currentChild); }); } return elements; } /** * Handles an error while rendering. * @param {Element} element Component's HTML element. * @param {Object} component Component's instance. * @param {Error} error Error to handle. * @returns {Promise<string>} Promise for HTML string. * @private */ _handleRenderError(element, component, error) { this._eventBus.emit('error', error); return Promise.resolve() .then(() => { // do not corrupt existed HEAD when error occurs if (element.tagName === TAG_NAMES.HEAD) { return ''; } if (!this._config.isRelease && error instanceof Error) { return errorHelper.prettyPrint(error, this._window.navigator.userAgent); } else if (component.errorTemplate) { return component.errorTemplate.render(error); } return ''; }) .catch(() => ''); } /** * Updates all components that depend on the current set of changed stores. * @returns {Promise} Promise for nothing. * @private */ _updateStoreComponents() { if (this._isUpdating) { return Promise.resolve(); } // if document component is changed we should reload the page const documentStore = this._window.document.documentElement.getAttribute( moduleHelper.ATTRIBUTE_STORE ); if (documentStore in this._currentChangedStores) { const newLocation = this._currentRoutingContext.location.toString(); if (newLocation === this._window.location.toString()) { this._window.location.reload(); return Promise.resolve(); } this._window.location.assign(newLocation); return Promise.resolve(); } this._isUpdating = true; // if we have awaiting routing we should apply state to the stores if (this._awaitingRouting) { const components = this._componentLoader.getComponentsByNames(); const changedByState = this._storeDispatcher.setState( this._awaitingRouting.state, this._awaitingRouting.routingContext ); changedByState.forEach(name => { this._currentChangedStores[name] = true; }); // we should update contexts of the components with the new routing context this._currentRoutingContext = this._awaitingRouting.routingContext; Object.keys(this._componentInstances) .forEach(id => { const instance = this._componentInstances[id]; instance.$context = this._getComponentContext( components[instance.$context.name], instance.$context.element ); }); this._awaitingRouting = null; } const changedStores = Object.keys(this._currentChangedStores); if (changedStores.length === 0) { this._isUpdating = false; return Promise.resolve(); } this._currentChangedStores = Object.create(null); const renderingContext = this._createRenderingContext(changedStores); const promises = renderingContext.roots.map(root => { renderingContext.rootIds[this._getId(root)] = true; return this.renderComponent(root, renderingContext); }); return Promise.all(promises) .catch(reason => this._eventBus.emit('error', reason)) .then(() => { this._isUpdating = false; this._eventBus.emit('documentUpdated', changedStores); return this._updateStoreComponents(); }); } /** * Merges new and existed head elements and applies only difference. * The problem here is that we can't re-create or change script and style tags, * because it causes blinking and JavaScript re-initialization. Therefore such * element must be immutable in the HEAD. * @param {Node} head HEAD DOM element. * @param {Node} newHead New HEAD element. * @private */ _mergeHead(head, newHead) { if (!newHead) { return; } const headSet = Object.create(null); // remove all nodes from the current HEAD except immutable ones for (let i = 0; i < head.childNodes.length; i++) { const current = head.childNodes[i]; if (!isTagImmutable(current)) { head.removeChild(current); i--; continue; } // we need to collect keys for immutable elements to handle // attributes reordering headSet[this._getElementKey(current)] = true; } for (let i = 0; i < newHead.children.length; i++) { const current = newHead.children[i]; if (this._getElementKey(current) in headSet) { continue; } head.appendChild(current); // when we append existing child to another parent it removes // the node from a previous parent i--; } } /** * Gets an unique element key using element's attributes and its content. * @param {Element} element HTML element. * @returns {string} Unique key for the element. * @private */ _getElementKey(element) { // some immutable elements have several valuable attributes // these attributes define the element identity const attributes = []; switch (element.nodeName) { case TAG_NAMES.LINK: attributes.push(`href=${element.getAttribute('href')}`); break; case TAG_NAMES.SCRIPT: attributes.push(`src=${element.getAttribute('src')}`); break; } return `<${element.nodeName} ${attributes.sort().join(' ')}>${element.textContent}</${element.nodeName}>`; } /** * Initializes the element as a component. * @param {Element} element The component's element. * @param {Object} components Current set of components. * @returns {Promise} Promise for the done initialization. * @private */ _initializeComponent(element, components) { return Promise.resolve() .then(() => { const componentName = moduleHelper.getOriginalComponentName(element.nodeName); if (!(componentName in components)) { return null; } const id = this._getId(element); const ComponentConstructor = components[componentName].constructor; ComponentConstructor.prototype.$context = this._getComponentContext( components[componentName], element ); const instance = new ComponentConstructor(this._serviceLocator); instance.$context = ComponentConstructor.prototype.$context; this._componentElements[id] = element; this._componentInstances[id] = instance; // initialize the store of the component this._storeDispatcher.getStore( element.getAttribute(moduleHelper.ATTRIBUTE_STORE) ); this._eventBus.emit('componentRendered', { name: componentName, attributes: instance.$context.attributes, context: instance.$context }); return this._bindComponent(element); }); } /** * Gets a component context using the basic context. * @param {Object} component Component details. * @param {Element} element DOM element of the component. * @returns {Object} Component's context. * @private */ _getComponentContext(component, element) { const storeName = element.getAttribute(moduleHelper.ATTRIBUTE_STORE); const componentContext = Object.create(this._currentRoutingContext); // initialize the store of the component this._storeDispatcher.getStore(storeName); Object.defineProperties(componentContext, { name: { get: () => component.name, enumerable: true }, attributes: { get: () => attributesToObject(element.attributes), enumerable: true } }); componentContext.element = element; // search methods componentContext.getComponentById = id => this.getComponentById(id); componentContext.getComponentByElement = element => this.getComponentByElement(element); componentContext.getComponentsByTagName = (tagName, parent) => this.getComponentsByTagName(tagName, parent); componentContext.getComponentsByClassName = (className, parent) => this.getComponentsByClassName(className, parent); componentContext.queryComponentSelector = (selector, parent) => this.queryComponentSelector(selector, parent); componentContext.queryComponentSelectorAll = (selector, parent) => this.queryComponentSelectorAll(selector, parent); // create/remove componentContext.createComponent = (tagName, attributes) => this.createComponent(tagName, attributes); componentContext.collectGarbage = () => this.collectGarbage(); // store methods componentContext.getStoreData = () => { const currentStoreName = element.getAttribute(moduleHelper.ATTRIBUTE_STORE); return this._storeDispatcher.getStoreData(currentStoreName); }; componentContext.sendAction = (name, args) => { const currentStoreName = element.getAttribute(moduleHelper.ATTRIBUTE_STORE); return this._storeDispatcher.sendAction(currentStoreName, name, args); }; return Object.freeze(componentContext); } /** * Finds all rendering roots on the page for all changed stores. * @param {Array} changedStoreNames List of changed store's names. * @returns {Array<Element>} HTML elements that are rendering roots. * @private */ _findRenderingRoots(changedStoreNames) { const headStore = this._window.document.head.getAttribute(moduleHelper.ATTRIBUTE_STORE); const components = this._componentLoader.getComponentsByNames(); const componentElements = Object.create(null); const storeNamesSet = Object.create(null); const rootsSet = Object.create(null); const roots = []; // we should find all components and then look for roots changedStoreNames .forEach(storeName => { storeNamesSet[storeName] = true; const elements = this._window.document .querySelectorAll(`[${moduleHelper.ATTRIBUTE_STORE}="${storeName}"]`); if (elements.length === 0) { return; } componentElements[storeName] = elements; }); if (headStore in storeNamesSet && moduleHelper.HEAD_COMPONENT_NAME in components) { rootsSet[this._getId(this._window.document.head)] = true; roots.push(this._window.document.head); } changedStoreNames .forEach(storeName => { if (!(storeName in componentElements)) { return; } // we can optimize and don't go the same path twice const visitedIds = Object.create(null); Array.prototype.forEach.call(componentElements[storeName], current => { if (!moduleHelper.isComponentNode(current)) { return; } let currentRoot = current; let lastRoot = currentRoot; let lastRootId = this._getId(current); if (lastRootId in visitedIds) { return; } visitedIds[lastRootId] = true; while (currentRoot.parentElement) { currentRoot = currentRoot.parentElement; // if we go the same route we visited before we can // proceed with the next element const currentId = this._getId(currentRoot); if (currentId in visitedIds) { return; } const currentStore = currentRoot.getAttribute(moduleHelper.ATTRIBUTE_STORE); const currentComponentName = moduleHelper.getOriginalComponentName(currentRoot.tagName); // store did not change state if (!currentStore || !(currentStore in storeNamesSet)) { continue; } // this component element does not have an // implementation, skipping.... if (!(currentComponentName in components)) { continue; } lastRoot = currentRoot; lastRootId = currentId; } // we don't want the same root element twice if (lastRootId in rootsSet) { return; } rootsSet[lastRootId] = true; roots.push(lastRoot); }); }); return roots; } /** * Creates a rendering context. * @param {Array?} changedStores Names of changed stores. * @returns {{ * config: Object, * renderedIds: {}, * unboundIds: {}, * isHeadRendered: boolean, * bindMethods: Array, * routingContext: Object, * components: Object, * roots: Array.<Element> * }} The context object. * @private */ _createRenderingContext(changedStores) { const components = this._componentLoader.getComponentsByNames(); return { config: this._config, renderedIds: Object.create(null), unboundIds: Object.create(null), isHeadRendered: false, bindMethods: [], routingContext: this._currentRoutingContext, components, rootIds: Object.create(null), roots: changedStores ? this._findRenderingRoots(changedStores) : [] }; } /** * Gets an ID of the element. * @param {Element} element HTML element of the component. * @returns {string} ID. */ _getId(element) { if (element === this._window.document.documentElement) { return SPECIAL_IDS.$$document; } if (element === this._window.document.head) { return SPECIAL_IDS.$$head; } // if the element does not have an ID we create it if (!element[moduleHelper.COMPONENT_ID]) { element[moduleHelper.COMPONENT_ID] = uuid.v4(); // deal with possible collisions while (element[moduleHelper.COMPONENT_ID] in this._componentInstances) { element[moduleHelper.COMPONENT_ID] = uuid.v4(); } } return element[moduleHelper.COMPONENT_ID]; } /** * Gets a cross-browser "matches" method for the element. * @param {Element} element HTML element. * @returns {Function} "matches" method. * @private */ _getMatchesMethod(element) { const method = ( element.matches || element.webkitMatchesSelector || element.mozMatchesSelector || element.oMatchesSelector || element.msMatchesSelector || (selector => matches(this._window, element, selector)) ); return method.bind(element); } } /** * Converts NamedNodeMap of Attr items to the key-value object map. * @param {NamedNodeMap} attributes List of Element attributes. * @returns {Object} Map of attribute values by their names. */ function attributesToObject(attributes) { const result = Object.create(null); Array.prototype.forEach.call(attributes, current => { result[current.name] = current.value; }); return result; } /** * Matches the specified element to the selector (fallback). * @param {Window} currentWindow Current browser window. * @param {Element} element DOM element. * @param {string} selector Selector for matching. * @returns {boolean} Does the element match. */ function matches(currentWindow, element, selector) { const ownerDocument = element.document || element.ownerDocument || currentWindow.document; const matched = ownerDocument.querySelectorAll(selector); return Array.prototype.some.call(matched, item => item === element); } /** * Creates an imitation of the original Event object but with specified currentTarget. * @param {Event} event Original event object. * @param {Function} currentTargetGetter Getter for the currentTarget. * @returns {Event} Wrapped event. */ function createCustomEvent(event, currentTargetGetter) { const catEvent = Object.create(event); const keys = []; const properties = {}; /* eslint guard-for-in: 0 */ for (const key in event) { keys.push(key); } keys.forEach(key => { if (typeof (event[key]) === 'function') { properties[key] = { get: () => event[key].bind(event) }; return; } properties[key] = { get: () => event[key], set: value => { event[key] = value; } }; }); properties.currentTarget = { get: currentTargetGetter }; Object.defineProperties(catEvent, properties); Object.seal(catEvent); Object.freeze(catEvent); return catEvent; } /** * Checks if we can mutate the specified HTML tag. * @param {Element} element The DOM element. * @returns {boolean} true if element should not be mutated. */ function isTagImmutable(element) { // these 3 kinds of tags once loaded can not be removed // otherwise it will cause style or script reloading return element.nodeName === TAG_NAMES.SCRIPT || element.nodeName === TAG_NAMES.STYLE || element.nodeName === TAG_NAMES.LINK && element.getAttribute('rel') === 'stylesheet'; } module.exports = DocumentRenderer;