Source: engine/DomRefreshEngine.js

(function() {
    /**
     * @typedef {Help4.engine.StateEngine.Params} Help4.engine.DomRefreshEngine.Params
     * @property {Help4.engine.crossorigin.CoreEngine} [crossOriginEngine = null]
     * @property {Help4.service.CrossOriginMessageService} [crossOriginService = null]
     * @property {number} [forceRefresh = 0]
     * @property {boolean} [isCrossOriginAgent = false]
     */

    /**
     * monitors DOM refresh events
     * @augments Help4.engine.StateEngine
     */
    Help4.engine.DomRefreshEngine = class extends Help4.engine.StateEngine {
        /**
         * @override
         * @param {Help4.engine.DomRefreshEngine.Params} params
         */
        constructor(params) {
            super(params, {
                crossOriginEngine: null,
                crossOriginService: null,
                forceRefresh: 0,
                isCrossOriginAgent: false
            });

            this._dirty = false;
            this._sleep = false;
            this._executors = [];

            const {WindowObserver, EventBusObserver, TimeObserver} = Help4.observer;
            const {
                _params: {crossOriginEngine, crossOriginService, eventBus, forceRefresh},
                _observers
            } = this;

            this._onCrossOriginEvent = (...args) => _onEvent.call(this, 'crossorigin', ...args);
            crossOriginEngine?.onDomRefreshEvent.addListener(this._onCrossOriginEvent);
            crossOriginService?.onWindowEvent.addListener(this._onCrossOriginEvent);

            _observers.windowObserver = new WindowObserver((...args) => _onEvent.call(this, 'window', ...args));
            if (eventBus) _observers.eventBusObserver = new EventBusObserver((...args) => _onEvent.call(this, 'eventBus', ...args));
            if (forceRefresh > 0) _observers.timeObserver = new TimeObserver((...args) => _onEvent.call(this, 'time', ...args));
        }

        /**
         * @memberof Help4.engine.DomRefreshEngine
         * @type {Object}
         * @property {Object} mutation
         * @property {true} mutation.childList
         * @property {true} mutation.attributes
         * @property {true} mutation.characterData
         * @property {true} mutation.subtree
         * @property {true} mutation.attributeOldValue
         * @property {true} mutation.characterDataOldValue
         * @property {Object} event
         * @property {Array<'resize', 'scroll', 'click'>} event.type
         * @property {true} event.capture
         * @property {true} focus
         */
        static OPTIONS = {
            mutation: {
                childList: true,
                attributes: true,
                characterData: true,
                subtree: true,
                attributeOldValue: true,
                characterDataOldValue: true
            },
            event: {
                type: ['resize', 'scroll', 'click'],
                capture: true
            },
            focus: true
        }

        /** @override */
        destroy() {
            const {_params: {crossOriginEngine, crossOriginService} = {}, _onCrossOriginEvent} = this;
            if (_onCrossOriginEvent) {
                crossOriginEngine?.onDomRefreshEvent.removeListener(_onCrossOriginEvent);
                crossOriginService?.onWindowEvent.removeListener(_onCrossOriginEvent);
                delete this._onCrossOriginEvent;
            }

            super.destroy();

            delete this._dirty;
            delete this._sleep;
            delete this._executors;
        }

        /** @override */
        start() {
            if (!Help4.observer.MutationObserver.isSupported() || this._started) return;
            super.start();

            const {
                _params: {eventBus, forceRefresh, crossOriginEngine, crossOriginService},
                _observers: {windowObserver, eventBusObserver, timeObserver}
            } = this;

            const {OPTIONS: {mutation, event, focus}} = Help4.engine.DomRefreshEngine;
            windowObserver.observeAll({mutationObserver: mutation, eventObserver: event, focusObserver: focus});

            if (eventBus) {
                const {TYPES} = eventBus;
                eventBusObserver.observe(eventBus, {type: [TYPES.controllerOpen, TYPES.controllerAfterNavigate, TYPES.hotspotAssignStop]});
            }

            forceRefresh > 0 && timeObserver.observe('interval', {time: forceRefresh});

            const agentType = Help4.engine.crossorigin.AGENT_TYPE.recording;
            crossOriginEngine?.sendCommand({type: agentType, command: 'startDomRefreshEngine'})
            .catch(Help4.noop);

            crossOriginService?.startDomRefreshEngine(agentType);

            this._dirty = true;
        }

        /** @override */
        stop() {
            if (!this._started) return;

            this._dirty = false;

            const {crossOriginEngine, crossOriginService} = this._params;

            const agentType = Help4.engine.crossorigin.AGENT_TYPE.recording;
            crossOriginEngine?.sendCommand({type: agentType, command: 'stopDomRefreshEngine'})
            .catch(Help4.noop);

            crossOriginService?.stopDomRefreshEngine(agentType);

            super.stop();
        }

        /**
         * @param {boolean} sleep
         */
        sleep(sleep) {
            this._sleep = sleep;
            this._dirty = false;

            const {crossOriginEngine} = this._params;
            const agentType = Help4.engine.crossorigin.AGENT_TYPE.recording;
            const command = sleep ? 'sleepDomRefreshEngine' : 'wakeDomRefreshEngine';

            crossOriginEngine?.sendCommand({type: agentType, command})
            .catch(Help4.noop);
        }

        /** @returns {Help4.engine.DomRefreshEngine} */
        forceDirty() {
            this._dirty = true;
            return this;
        }

        /**
         * @param {Function} executor
         *  @returns {Help4.engine.DomRefreshEngine}
         */
        addExecutor(executor) {
            const {_executors} = this;
            _executors.indexOf(executor) < 0 && _executors.push(executor);
            return this;
        }

        /**
         * @param {Function} executor
         * @returns {Help4.engine.DomRefreshEngine}
         */
        removeExecutor(executor) {
            if (!this.isDestroyed()) {
                const {_executors} = this;
                const idx = _executors.indexOf(executor);
                idx >= 0 && _executors.splice(idx, 1);
            }
            return this;
        }

        /** @returns {Help4.engine.DomRefreshEngine} */
        execute() {
            _onEvent.call(this, 'API', {type: 'execute'});
            return this;
        }
    }

    /**
     * @memberof Help4.engine.DomRefreshEngine#
     * @private
     * @param {string} observerId
     * @param {Object} event
     */
    function _onEvent(observerId, event) {
        if (!this._started) return;

        switch (observerId) {
            case 'window':
                if (!_isMutationResult.call(this, event) || _hasValidMutationRecords.call(this, event)) {
                    if (!this._sleep) this._dirty = true;
                }
                break;

            case 'crossorigin':
            case 'eventBus':
            case 'time':
                if (!this._sleep) this._dirty = true;
                break;

            case 'API':
                if (event.type === 'execute' && this._dirty) {
                    this._dirty = false;
                    _execute.call(this);
                }
                break;
        }

        if (this._params.isCrossOriginAgent && this._dirty) {
            this._dirty = false;
            _execute.call(this);
        }
    }

    /**
     * @memberof Help4.engine.DomRefreshEngine#
     * @private
     */
    function _execute() {
        const {_executors} = this;
        for (const executor of _executors) {
            executor();
        }
    }

    /**
     * @memberof Help4.engine.DomRefreshEngine#
     * @private
     * @param {MutationRecord[]} mutationRecords
     * @returns {boolean}
     */
    function _hasValidMutationRecords(mutationRecords) {
        // a different record exists; this is a real change
        if (mutationRecords.find(({type: t, attributeName: a}) => t !== 'attributes' || a !== 'class')) return true;
        // uneven number of records; real change
        if (mutationRecords.length % 2 === 1) return true;

        // attention: XRAY-5522 - do not touch MutationRecord.target!

        // variations for to-be-filtered elements:
        //
        // 1. one class is added and removed
        // [{
        //   oldValue: "", ...
        // }, {
        //   oldValue: "help4-enforce-pointer-events", ...
        // }]
        //
        // 2. one class is added and remove
        // [{
        //   oldValue: "", ...
        // }, {
        //   oldValue: "help4-enforce-inline-block", ...
        // }]
        //
        // 3. two classes are added and removed
        // [{
        //   oldValue: "", ...
        // }, {
        //   oldValue: "help4-enforce-inline-block", ...
        // }, {
        //   oldValue: "help4-enforce-inline-block help4-enforce-pointer-events", ...
        // }, {
        //   oldValue: "help4-enforce-pointer-events", ...
        // }]

        const {CLASS_PREFIX: CP, ENFORCE_POINTER_EVENTS_CLASS: EPEC, ENFORCE_INLINE_BLOCK_CLASS: EIBC} = Help4;
        const search1 = CP + EPEC;
        const search2 = CP + EIBC;

        // DEBUG
        // mutationRecords = mutationRecords.map(({oldValue}) => ({oldValue}));
        // mutationRecords.push({oldValue: ''}, {oldValue: 'help4-enforce-pointer-events'});  // EXAMPLE 1
        // mutationRecords.push({oldValue: ''}, {oldValue: 'help4-enforce-inline-block'});  // EXAMPLE 2.1
        // mutationRecords.push({oldValue: 'abc'}, {oldValue: 'abc help4-enforce-inline-block'});  // EXAMPLE 2.2
        // mutationRecords.push({oldValue: ''}, {oldValue: 'help4-enforce-inline-block'}, {oldValue: 'help4-enforce-inline-block help4-enforce-pointer-events'}, {oldValue: 'help4-enforce-pointer-events'});  // EXAMPLE 3.1
        // mutationRecords.push({oldValue: ''}, {oldValue: 'help4-enforce-pointer-events'}, {oldValue: 'help4-enforce-pointer-events help4-enforce-inline-block'}, {oldValue: 'help4-enforce-inline-block'});  // EXAMPLE 3.2
        // mutationRecords.push({oldValue: 'abc'}, {oldValue: 'abc help4-enforce-pointer-events'}, {oldValue: 'abc help4-enforce-pointer-events help4-enforce-inline-block'}, {oldValue: 'abc help4-enforce-inline-block'});  // EXAMPLE 3.3
        // DEBUG

        // all combinations that include both classes
        const filterBoth = mutationRecords.filter(({oldValue}) => oldValue?.includes(search1) && oldValue?.includes(search2));
        // all combinations that include one class
        const filterOne = mutationRecords.filter(({oldValue}) => oldValue?.includes(search1) || oldValue?.includes(search2));

        // a) each combination of 2-classes creates 4 entries
        // b) each combination of 1-class creates 2 entries
        // c) some 1-class entries belong to the 2-classes
        const both = filterBoth.length;  // 4 entries per 2-classes
        const one = filterOne.length - both * 3;  // 2 entries per 1-class minus the 3 entries from 2-classes
        const notOurs = mutationRecords.length - both * 4 - one * 2;

        // records left; real change
        return notOurs > 0;
    }

    /**
     * @memberof Help4.engine.DomRefreshEngine#
     * @private
     * @param {*} event
     * @returns {boolean}
     */
    function _isMutationResult(event) {
        return Help4.isArray(event) && event[0] instanceof MutationRecord;
    }
})();