UNPKG

reblendjs

Version:

This is build using react way of handling dom but with web components

492 lines 17 kB
/* 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; } }