Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

performance(core): improve component resolution and channel connections for CE #17

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions packages/core/src/adapter/element.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
/*
* Copyright 2024 Bilbao Vizcaya Argentaria, S.A.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { attributeWhiteList, setAttribute } from '../utils.js';

const dashToCamelCase = input => {
return input.toLowerCase().replace(/-(.)/g, function (match, group1) {
return group1.toUpperCase();
});
};

/**
* Checks if the given string is a valid custom element tag name
* @param {string} tagName - element tag mane
* @returns {boolean}
*/
export function isCustomElementTagName(tagName) {
return tagName.includes('-');
}

/**
* Invoke a given function when element has been defined, non custom elements
* callback is inmediallty executed
* @param {Element} node - given node element to wait for registration
* @param {Function} cb - callback to be executed
*/
export function whenElementDefined(node, cb) {
const nodeName = node.tagName.toLowerCase();
if (isCustomElementTagName(nodeName)) {
window.customElements.whenDefined(nodeName)
.then(() => {
try {
// prevent to execute callback when node has not been upgrated after element registration
window.customElements.upgrade(node);
} finally {
cb();
}
});
} else {
cb();
}
}

/**
* Checks if the given node is a web component and has been registered into
* CustomElementRegistry
* @param {Element} node
* @returns {boolean}
*/
export function elementHasBeenResolved(node) {
const nodeName = node.tagName.toLowerCase();
return !isCustomElementTagName(nodeName) || !!window.customElements.get(nodeName);
}

export function isEventAtTarget(event) {
const AT_TARGET_VALUE = Event.AT_TARGET || Event.prototype.AT_TARGET;
return event.eventPhase === AT_TARGET_VALUE;
}

/**
* Dispatches a function from a given HTMLElement.
* @param {HTMLElement} target - element to be invoked
* @param {(keyof HTMLElement)} method - method to be executed
* @param {CustomEvent} e - event with the current value
*/
export function dispatchNodeFunction(target, method, e) {
if (target[method] && typeof target[method] === 'function') {
target[method](e.detail)
}
}

/**
* Sets given value from a given HTMLElement.
* @template {HTMLElement} T
* @param {T} target - element to be invoked
* @param {(keyof T)} property - property to be setted
* @param {CustomEvent} e - event with the current value
*/
export function dispatchNodeProperty(target, property, e) {
const changedProperty = getChangedProperty(e.type);
let value;
if(changedProperty && e.detail && Object.prototype.hasOwnProperty.call(evt.detail, 'value')) {
value = e.detail.value;
} else {
value = e.detail;
}

if(!attributeWhiteList.includes(property) && target[property]) {
target[property] = value;
} else {
setAttribute(target, property, value);
}
}

/**
* Gets the property of a changed event name.
* @param {string} name - event type
*/
function getChangedProperty(name) {
const EVENT_CHANGED = '-changed';

if (name.indexOf(EVENT_CHANGED, name.length - EVENT_CHANGED.length) !== -1) {
let propertyName;
propertyName = name.slice(0, -EVENT_CHANGED.length);
propertyName = dashToCamelCase(propertyName);
return propertyName;
}

return null;
}
158 changes: 63 additions & 95 deletions packages/core/src/component-connector.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,13 @@ import { fromEvent } from 'rxjs';
import { eventManager } from './manager/events.js';
import { Subscriptor } from './state/index.js';
import { Constants } from './constants.js';
import { ElementAdapter } from './adapter/element-adapter.js';
import {
dispatchNodeFunction,
dispatchNodeProperty,
elementHasBeenResolved,
isEventAtTarget,
whenElementDefined
} from './adapter/element.js';
import { ChannelManager } from './manager/channel-manager.js';
import { BRIDGE_CHANNEL_PREFIX } from './constants.js';

Expand Down Expand Up @@ -49,6 +55,57 @@ import { BRIDGE_CHANNEL_PREFIX } from './constants.js';
/** @constant externalEventsCodes */
const { externalEventsCodes } = Constants;

function requestIdleCallbackImpl(cb) {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(cb);
} else {
setTimeout(cb, 0);
}
}

/**
* Dispatch a given bindign action to a target node
* @param {Element} node
* @param {Binding} binding
* @param {CustomEvent} e
*/
function dispatchNodeAction(node, binding, e) {
if (typeof binding === 'function') {
binding(e.detail);
} else if (typeof node[binding] === 'function'){
dispatchNodeFunction(node, binding, e);
} else {
dispatchNodeProperty(node, binding, e);
}
}

/**
* Creates an action to be dispatched to a given node, if node is not already
* registered, action will be delayed until element can be resolved
* @param {Element} node
* @param {Binding} binding
*/
function createNodeAction(node, binding) {
let action = (/** @type {CustomEvent} */e) => {
if (!elementHasBeenResolved(node)) {
// TODO: is requestIdleCallback needed to delay executon after custom element registration?
whenElementDefined(node, requestIdleCallbackImpl(() => {
dispatchNodeAction(node, binding, e);
}));
} else {
dispatchNodeAction(node, binding, e);
}
}

// REVIEW: check if this does not override the node property set in the subscribe method
Object.defineProperty(action, /** @type {WCNode} */ 'node', {
writable: true,
configurable: true,
enumerable: true,
});
return action;
}

/**
* Represents a Component Connector that manages subscriptions and publications between components.
*
Expand All @@ -57,13 +114,6 @@ const { externalEventsCodes } = Constants;
export class ComponentConnector {
/** Creates a new instance of ComponentConnector. */
constructor() {
/**
* The adapter for element.
*
* @type {ElementAdapter}
*/
this.adapter = new ElementAdapter(this);

/**
* The channel manager used to manage channels.
*
Expand Down Expand Up @@ -114,12 +164,12 @@ export class ComponentConnector {
* @param {boolean} [previousState=false] - The previous state flag. Default is `false`
*/
addSubscription(channelName, node, bind, previousState = false) {
const callback = this._wrapCallbackWithNode(node, bind);
const channel = this.manager.get(channelName);

if (channel) {
const subscriptor = this.getSubscriptor(node);
subscriptor.subscribe(channel, callback, previousState, bind);
const action = createNodeAction(node, bind)
subscriptor.subscribe(channel, action, previousState, bind);
}
}

Expand Down Expand Up @@ -172,94 +222,12 @@ export class ComponentConnector {
(!this.isActiveBridgeChannel(channelName) && !this.hasSubscriptions(subscriptor, channelName))
) {
const channel = this.manager.get(channelName);
const callback = this._wrapCallbackWithNode(node, bind);
const action = createNodeAction(node, bind);

subscriptor.subscribe(channel, callback, previousState, bind);
subscriptor.subscribe(channel, action, previousState, bind);
}
}

/**
* Wrap a callback function with the given node and bind name.
*
* @param {WCNode} node - The node to wrap the callback with.
* @param {Binding} bindName - The bind name.
* @returns {AugmentedFunction} The wrapped callback function.
*/
_wrapCallbackWithNode(node, bindName) {
//let cb = this.wrapCallback(node, bindName);
//cb.node = node;
//return cb;
return this.wrapCallback(node, bindName);
}

/**
* Wrap a callback function with the given node and bind name.
*
* @param {IndexableHTMLElement} node - The node to wrap the callback with.
* @param {Binding} bindName - The bind name.
* @returns {AugmentedFunction} The wrapped callback function. re t urns {function(Event): void} -
* The wrapped callback function that expects an Event parameter and returns void.
*/
wrapCallback(node, bindName) {
const _idleCallback = (/** @type IdleRequestCallback */ fn) => {
setTimeout(function () {
if ('requestIdleCallback' in window) {
window.requestIdleCallback(fn);
} else {
setTimeout(fn, 1);
}
}, 100);
};

/**
* @param {WCEvent} evt - The event.
* @returns {void}
*/
const wrappedCallback = evt => {
/**
* @param {MutationRecord[]} mutationsList - The mutations that were observed.
* @param {MutationObserver} observerObject - The MutationObserver instance.
* @returns {void}
*/
const checkComponentResolution = (mutationsList, observerObject) => {
if (!this.adapter.isUnresolved(node)) {
checkDispatchActionType();

if (observerObject) {
observerObject.disconnect();
}
} else {
_idleCallback(checkDispatchActionType);
}
};

const checkDispatchActionType = () => {
if (typeof bindName === 'function' || typeof node[bindName] === 'function') {
this.adapter.dispatchActionFunction(evt, node, bindName);
} else {
this.adapter.dispatchActionProperty(evt, node, bindName);
}
};

if (this.adapter.isUnresolved(node)) {
var observer = new MutationObserver(checkComponentResolution);
var config = { attributes: false, childList: true, characterData: true };
observer.observe(node, config);
_idleCallback(checkDispatchActionType);
} else {
checkDispatchActionType();
}
};

// REVIEW: check if this does not override the node property set in the subscribe method
Object.defineProperty(wrappedCallback, /** @type {WCNode} */ 'node', {
writable: true,
configurable: true,
enumerable: true,
});
return wrappedCallback;
}

/**
* Check if a node has a publisher for the given channel and bind name.
*
Expand Down Expand Up @@ -400,7 +368,7 @@ export class ComponentConnector {

/** @type {Subscription} */
const wrappedListener = source.subscribe((/** @type {WCEvent} */ event) => {
if (!this.adapter.isEventAtTarget(event)) {
if (isEventAtTarget(event)) {
// If the event bubbles up from a child element:
return;
}
Expand Down