reblendjs
Version:
This is build using react way of handling dom but with web components
492 lines • 17 kB
JavaScript
/* eslint-disable @typescript-eslint/no-explicit-any */
import { rand } from '../common/utils';
import StyleUtil from './StyleUtil';
import { NodeUtil } from './NodeUtil';
import { ElementUtil } from './ElementUtil';
import { DiffUtil } from './DiffUtil';
import { NodeOperationUtil } from './NodeOperationUtil';
import { ReblendReactClass } from './ReblendReactClass';
StyleUtil;
const stateIdNotIncluded = new Error('State Identifier/Key not specified');
//@ts-expect-error We don't have to redefine HTMLElement methods we just added it for type safety
export class BaseComponent {
static ELEMENT_NAME = 'BaseComponent';
static props;
static config;
static async wrapChildrenToReact(components) {
const elementChildren = await ElementUtil.createElement(components);
return await ReblendReactClass.getChildrenWrapperForReact(elementChildren);
}
static construct(displayName, props, ...children) {
if (Array.isArray(displayName)) {
return displayName;
}
const clazz = displayName;
const isTagStandard = typeof displayName === 'string';
if (!isTagStandard && clazz.ELEMENT_NAME === 'Fragment') {
return children || [];
}
if (clazz?.props?.children && !Array.isArray(clazz?.props?.children) || props?.children && !Array.isArray(props?.children)) {
throw new Error('Children props must be an array of ReblendNode or HTMLElement');
}
const mergedProp = {
...(!isTagStandard && clazz.props ? clazz.props : {}),
...props
};
if (clazz?.props?.children || props?.children || children.length) {
mergedProp.children = [...(clazz?.props?.children || []), ...(props?.children || []), ...(children || [])];
}
const velement = {
displayName: clazz,
props: mergedProp
};
NodeUtil.addSymbol(isTagStandard ? 'ReblendVNodeStandard' : NodeUtil.isReactNode(clazz) ? 'ReactToReblendVNode' : 'ReblendVNode', velement);
return velement;
}
static async mountOn(elementId, app, props) {
let appRoot = document.getElementById(elementId);
if (!appRoot) {
throw new Error('Invalid root id');
}
let root = document.createElement('div');
root.setAttribute('Root', '');
let initialDisplay = root.style.display || 'initial';
let body = document.body;
let preloaderParent = document.createElement('div');
preloaderParent.setAttribute('preloaderParent', '');
body.appendChild(preloaderParent);
const openPreloader = () => {
root.style.display = 'none';
preloaderParent.style.display = 'initial';
};
// A new mount function that processes nodes one by one,
// yielding after each node so the browser can update the UI.
const mountChunked = async (parent, nodes) => {
for (const node of nodes) {
parent.appendChild(node);
setTimeout(() => NodeOperationUtil.connected(node), 0);
// Yield to the browser to allow UI updates (e.g., the preloader animation).
await new Promise(resolve => requestAnimationFrame(resolve));
}
};
// Load and mount the preloader.
let {
Preloader
} = await import('./components/Preloader');
let preloaderVNodes = BaseComponent.construct(Preloader, {}, ...[]);
let preloaderNodes = await ElementUtil.createChildren(Array.isArray(preloaderVNodes) ? preloaderVNodes : [preloaderVNodes]);
openPreloader();
await mountChunked(preloaderParent, preloaderNodes);
// Construct the main app nodes.
let vNodes = BaseComponent.construct(app, props || {}, ...[]);
let nodes = await ElementUtil.createChildren(Array.isArray(vNodes) ? vNodes : [vNodes]);
// Optionally, wait a short time (500ms) before mounting the main app.
await new Promise(resolve => setTimeout(resolve, 500));
mountChunked(root, nodes);
// Final yield to ensure all rendering tasks are complete.
await new Promise(resolve => requestAnimationFrame(resolve));
const closePreloader = () => {
root.style.display = initialDisplay;
preloaderParent.style.display = 'none';
appRoot?.appendChild(root);
preloaderParent.remove();
preloaderNodes.forEach(n => {
NodeOperationUtil.detach(n);
});
appRoot = undefined;
preloaderParent = undefined;
root = undefined;
initialDisplay = undefined;
body = undefined;
vNodes = undefined;
nodes = undefined;
Preloader = undefined;
preloaderVNodes = undefined;
preloaderNodes = undefined;
};
setTimeout(() => {
requestAnimationFrame(closePreloader);
}, 100);
}
async createInnerHtmlElements() {
let htmlVNodes = await this.html();
if (!Array.isArray(htmlVNodes)) {
htmlVNodes = [htmlVNodes];
}
htmlVNodes = DiffUtil.flattenVNodeChildren(htmlVNodes);
const htmlElements = await ElementUtil.createChildren(htmlVNodes);
return htmlElements;
}
async populateHtmlElements() {
if (this.hasDisconnected) {
return;
}
try {
const isReactReblend = NodeUtil.isReactToReblendRenderedNode(this);
//This is a guard against race condition where parent state changes before populating this component elements
if (isReactReblend && (this.elementChildren?.size || this.reactElementChildrenWrapper)) {
return;
}
const htmlElements = await this.createInnerHtmlElements();
htmlElements.forEach(node => node.directParent = this);
this.elementChildren = new Set(htmlElements);
if (this.removePlaceholder) {
this.removePlaceholder();
}
if (isReactReblend) {
this.reactReblendMount && this.reactReblendMount();
} else {
this.append(...htmlElements);
if (NodeUtil.isReblendRenderedNode(this) && this.awaitingInitState) {
NodeOperationUtil.connected(this);
}
}
this.childrenInitialize = true;
} catch (error) {
this.handleError(error);
}
}
connectedCallback() {
if (this.initStateRunning) {
this.awaitingInitState = true;
if (this.isPlaceholder) {
return;
} else if (this.ReblendPlaceholder) {
let placeholderVNodes;
if (typeof this.ReblendPlaceholder === 'function') {
placeholderVNodes = BaseComponent.construct(this.ReblendPlaceholder, {});
} else {
placeholderVNodes = this.ReblendPlaceholder;
}
ElementUtil.createElement(placeholderVNodes).then(placeholderElements => {
if (!this.childrenInitialize) {
if (this.placeholderAttached) {
return;
}
this.append(...placeholderElements);
this.placeholderAttached = true;
this.removePlaceholder = async () => {
placeholderElements.forEach(placeholderElement => NodeOperationUtil.detach(placeholderElement));
this.removePlaceholder = undefined;
};
placeholderElements.forEach(placeholderElement => {
placeholderElement.directParent = this;
placeholderElement.isPlaceholder = true;
NodeOperationUtil.connected(placeholderElement);
});
requestAnimationFrame(() => {
/* empty */
});
}
});
} else {
import('./components/Placeholder').then(async ({
default: Placeholder
}) => {
const placeholderVNodes = BaseComponent.construct(Placeholder, {
style: this.defaultReblendPlaceholderStyle
});
const placeholderElements = await ElementUtil.createElement(placeholderVNodes);
if (!this.childrenInitialize) {
if (this.placeholderAttached) {
return;
}
this.append(...placeholderElements);
this.placeholderAttached = true;
this.removePlaceholder = async () => {
placeholderElements.forEach(placeholderElement => NodeOperationUtil.detach(placeholderElement));
this.removePlaceholder = undefined;
};
placeholderElements.forEach(placeholderElement => {
placeholderElement.directParent = this;
placeholderElement.isPlaceholder = true;
NodeOperationUtil.connected(placeholderElement);
});
requestAnimationFrame(() => {
/* empty */
});
}
});
}
return;
}
NodeOperationUtil.connectedCallback(this);
}
addDisconnectedEffect(effect) {
this.disconnectEffects?.add(effect);
}
addStyle(style) {
if (!style) {
return;
}
if (typeof style === 'string') {
this.setAttribute('style', style);
} else if (Array.isArray(style)) {
const styleString = style.join(';');
this.setAttribute('style', styleString);
} else {
for (const [styleKey, value] of Object.entries(style)) {
this.style[styleKey] = value;
}
}
}
async initState() {
/* The state property has been initialize in `@_constructor` */
}
async initProps(props) {
this.props = props || {};
}
componentDidMount() {
/* Optionally implement this in class component */
}
setState(value) {
this.state = value;
this.onStateChange();
}
applyEffects() {
this.effectsFn?.forEach(effectFn => {
effectFn();
});
}
handleError(error) {
if (this.renderingErrorHandler) {
this.renderingErrorHandler((error.component = this, error));
} else if (this.state?.renderingErrorHandler && typeof this.state.renderingErrorHandler === 'function') {
this.state.renderingErrorHandler(error);
} else if (this.directParent) {
this.directParent.handleError(error);
} else {
throw error;
}
}
catchErrorFrom(fn) {
try {
fn.bind(this)();
} catch (error) {
this.handleError.bind(this)(error);
}
}
cacheEffectDependencies() {
Object.entries(this.effectState).forEach(([_key, value]) => {
value.cache = value.cacher();
});
}
async onStateChange() {
if (!this.attached || this.hasDisconnected) {
return;
}
if (NodeUtil.isStandard(this)) {
return;
}
if (this.stateEffectRunning) {
this.cacheEffectDependencies();
return;
}
if (this.onStateChangeRunning || this.initStateRunning) {
this.numAwaitingUpdates++;
return;
}
const patches = [];
let newVNodes;
try {
this.stateEffectRunning = true;
this.applyEffects();
this.stateEffectRunning = false;
this.onStateChangeRunning = true;
if (this.childrenInitialize) {
newVNodes = await this.html();
if (!Array.isArray(newVNodes)) {
newVNodes = [newVNodes];
}
newVNodes = DiffUtil.flattenVNodeChildren(newVNodes);
const oldNodes = [...(this.elementChildren?.values() || [])];
const maxLength = Math.max(oldNodes.length || 0, newVNodes.length);
for (let i = 0; i < maxLength; i++) {
const newVNode = newVNodes[i];
const currentVNode = oldNodes[i];
patches.push(...NodeOperationUtil.diff(this, currentVNode, newVNode));
}
}
} catch (error) {
this.handleError(error);
} finally {
this.onStateChangeRunning = false;
await NodeOperationUtil.applyPatches(patches);
if (this.numAwaitingUpdates) {
this.numAwaitingUpdates = 0;
setTimeout(() => this.onStateChange(), 0);
}
newVNodes = null;
}
}
async html() {
return null;
}
mountEffects() {
this.mountingEffects = true;
this.stateEffectRunning = true;
this.effectsFn?.forEach(fn => {
const disconnectEffect = fn();
if (disconnectEffect instanceof Promise) {
disconnectEffect.then(val => {
if (val) {
this.disconnectEffects?.add(val);
}
});
} else if (typeof disconnectEffect === 'function') {
this.disconnectEffects?.add(disconnectEffect);
}
});
this.mountingEffects = false;
this.stateEffectRunning = false;
if (NodeUtil.isReblendRenderedNode(this)) {
Promise.resolve().then(() => {
this.onStateChange();
});
}
}
disconnectedCallback(fromCleanUp = false) {
NodeOperationUtil.disconnectedCallback(this, fromCleanUp);
}
cleanUp() {
/* Cleans up resources before the component unmounts. */
}
componentWillUnmount() {
/* Lifecycle method for component unmount actions. */
}
dependenciesChanged(currentDependencies, previousDependencies) {
if (!previousDependencies || previousDependencies.length !== currentDependencies?.length) {
return false;
}
return currentDependencies.some((dep, index) => {
return !Object.is(dep, previousDependencies[index]);
});
}
useState(initial, ...dependencyStringAndOrStateKey) {
const stateID = dependencyStringAndOrStateKey.pop();
if (!stateID) {
throw stateIdNotIncluded;
}
if (typeof initial === 'function') {
initial = initial();
this.state[stateID] = initial;
} else if (initial instanceof Promise) {
initial.then(val => this.state[stateID] = val);
}
const variableSetter = (async (value, force = false) => {
if (typeof value === 'function') {
value = await value(this.state[stateID]);
} else if (value instanceof Promise) {
value = await value;
}
if (force || this.state[stateID] !== value) {
this.state[stateID] = value;
if (this.attached) {
Promise.resolve().then(() => this.onStateChange());
}
}
}).bind(this);
return [initial, variableSetter];
}
useEffect(fn, dependencies,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
..._dependencyStringAndOrStateKey) {
fn = fn.bind(this);
const dep = new Function(`return (${dependencies})`).bind(this);
const generateId = () => {
const id = rand(10000, 999999) + '_effectId';
if (this.effectState[id]) {
return generateId();
}
return id;
};
const effectKey = generateId();
const cacher = () => dep();
this.effectState[effectKey] = {
cache: cacher(),
cacher: cacher
};
const internalFn = (() => {
const current = cacher();
if (!dependencies || this.mountingEffects || this.dependenciesChanged(current, this.effectState[effectKey].cache)) {
this.effectState[effectKey].cache = current;
return fn();
}
}).bind(this);
this.effectsFn?.add(internalFn);
}
useReducer(reducer, initial, ...dependencyStringAndOrStateKey) {
reducer = reducer.bind(this);
const stateID = dependencyStringAndOrStateKey.pop();
if (!stateID) {
throw stateIdNotIncluded;
}
const [state, setState] = this.useState(initial, stateID);
this.state[stateID] = state;
const fn = (async newValue => {
let reducedVal;
if (typeof newValue === 'function') {
reducedVal = await reducer(this.state[stateID], newValue(this.state[stateID]));
} else {
reducedVal = await reducer(this.state[stateID], newValue);
}
setState(reducedVal);
}).bind(this);
return [this.state[stateID], fn];
}
useMemo(fn, dependencies, ...dependencyStringAndOrStateKey) {
fn = fn.bind(this);
const stateID = dependencyStringAndOrStateKey.pop();
if (!stateID) {
throw stateIdNotIncluded;
}
const [state, setState] = this.useState(fn(), stateID);
this.state[stateID] = state;
const dep = new Function(`return (${dependencies})`).bind(this);
const generateId = () => {
const id = rand(10000, 999999) + '_effectId';
if (this.effectState[id]) {
return generateId();
}
return id;
};
const effectKey = generateId();
const cacher = () => dep();
this.effectState[effectKey] = {
cache: cacher(),
cacher: cacher
};
const internalFn = async () => {
const current = cacher();
if (!dependencies || this.mountingEffects || this.dependenciesChanged(current, this.effectState[effectKey].cache)) {
this.effectState[effectKey].cache = current;
setState(fn());
}
};
this.effectsFn?.add(internalFn);
return this.state[stateID];
}
useRef(initial, stateKey) {
const ref = {
stateKey,
current: initial
};
return ref;
}
useCallback(fn) {
return fn.bind(this);
}
/**
* Initializes the component, preparing effect management.
* For compatibility in case a standard element inherits this prototype; can manually execute this constructor.
*/
_constructor() {
this.state = {};
this.effectsFn = new Set();
this.disconnectEffects = new Set();
this.childrenPropsUpdate = new Set();
this.numAwaitingUpdates = 0;
this.effectState = {};
this.hasDisconnected = false;
}
}