reblendjs
Version:
This is build using react way of handling dom but with web components
648 lines (582 loc) • 21.3 kB
text/typescript
/* eslint-disable @typescript-eslint/no-explicit-any */
import { rand } from '../common/utils'
import { IAny } from '../interface/IAny'
import StyleUtil from './StyleUtil'
import { ChildrenPropsUpdateType, ReblendTyping } from 'reblend-typing'
import { Reblend } from './Reblend'
import { NodeUtil } from './NodeUtil'
import { ElementUtil } from './ElementUtil'
import { DiffUtil } from './DiffUtil'
import { NodeOperationUtil } from './NodeOperationUtil'
import { CSSProperties } from 'react'
import { ReblendReactClass } from './ReblendReactClass'
StyleUtil
export interface BaseComponent<P, S> extends HTMLElement {
nearestStandardParent?: HTMLElement
onStateChangeRunning: boolean | undefined
elementChildren: Set<ReblendTyping.Component<P, S>> | null
reactElementChildrenWrapper: ReblendTyping.Component<any, any> | null
directParent: ReblendTyping.Component<any, any>
childrenInitialize: boolean
dataIdQuerySelector: string
props: Readonly<P>
reactDomCreateRoot_root: import('react-dom/client').Root | null
renderingError: ReblendTyping.ReblendRenderingException<P, S>
displayName: string
renderingErrorHandler: (e: ReblendTyping.ReblendRenderingException<P, S>) => void
removePlaceholder: () => Promise<void>
attached: boolean
isPlaceholder: boolean
placeholderAttached: boolean
ReactClass: any
ReblendPlaceholder?: ReblendTyping.VNode | typeof Reblend
defaultReblendPlaceholderStyle: CSSProperties | string
ref: ReblendTyping.Ref<HTMLElement> | ((node: HTMLElement) => any)
effectState: {
[key: string]: {
cache: ReblendTyping.Primitive | Array<ReblendTyping.Primitive>
cacher: () => ReblendTyping.Primitive | Array<ReblendTyping.Primitive>
}
}
effectsFn: Set<ReblendTyping.StateEffectiveFunction>
disconnectEffects: Set<ReblendTyping.StateEffectiveFunction>
checkPropsChange(): Promise<void>
hasDisconnected: boolean
htmlElements: ReblendTyping.Component<P, S>[]
childrenPropsUpdate: Set<ChildrenPropsUpdateType>
numAwaitingUpdates: number
stateEffectRunning: boolean
mountingEffects: boolean
initStateRunning: boolean
awaitingInitState: boolean
state: S
reactReblendMount: undefined | ((afterNode?: HTMLElement) => any)
}
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<
P = Record<string, never>,
S extends { renderingErrorHandler?: (error: Error) => void } = Record<string, never>,
> implements ReblendTyping.Component<P, S>
{
[reblendComponent: symbol]: boolean
static ELEMENT_NAME = 'BaseComponent'
static props: IAny
static config?: ReblendTyping.ReblendComponentConfig
static async wrapChildrenToReact(
components: JSX.Element | ReblendTyping.JSXElementConstructor<Record<string, never>>,
) {
const elementChildren = await ElementUtil.createElement(components as any)
return await ReblendReactClass.getChildrenWrapperForReact(elementChildren)
}
static construct(
displayName: typeof Reblend | string | ReblendTyping.VNode[],
props: IAny,
...children: ReblendTyping.VNodeChildren
): ReblendTyping.VNode | ReblendTyping.VNodeChildren {
if (Array.isArray(displayName)) {
return displayName as []
}
const clazz: typeof Reblend = displayName as typeof Reblend
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 as any
}
static async mountOn(
elementId: string,
app: typeof Reblend | ReblendTyping.FunctionComponent,
props?: IAny,
): Promise<void> {
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: HTMLElement, nodes: BaseComponent[]) => {
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<void>((resolve) => requestAnimationFrame(<any>resolve))
}
}
// Load and mount the preloader.
let { Preloader } = await import('./components/Preloader')
let preloaderVNodes = BaseComponent.construct(Preloader as any, {}, ...[])
let preloaderNodes: BaseComponent[] = (await ElementUtil.createChildren(
Array.isArray(preloaderVNodes) ? preloaderVNodes : [preloaderVNodes],
)) as any
openPreloader()
await mountChunked(preloaderParent, preloaderNodes)
// Construct the main app nodes.
let vNodes = BaseComponent.construct(app as any, props || {}, ...[])
let nodes: BaseComponent[] = (await ElementUtil.createChildren(
Array.isArray(vNodes) ? (vNodes as any) : [vNodes],
)) as any
// 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<void>((resolve) => requestAnimationFrame(<any>resolve))
const closePreloader = () => {
root.style.display = initialDisplay
preloaderParent.style.display = 'none'
appRoot?.appendChild(root)
preloaderParent.remove()
preloaderNodes.forEach((n) => {
NodeOperationUtil.detach(n)
})
appRoot = undefined as any
preloaderParent = undefined as any
root = undefined as any
initialDisplay = undefined as any
body = undefined as any
vNodes = undefined as any
nodes = undefined as any
Preloader = undefined as any
preloaderVNodes = undefined as any
preloaderNodes = undefined as any
}
setTimeout(() => {
requestAnimationFrame(closePreloader)
}, 100)
}
async createInnerHtmlElements() {
let htmlVNodes = await this.html()
if (!Array.isArray(htmlVNodes)) {
htmlVNodes = [htmlVNodes]
}
htmlVNodes = DiffUtil.flattenVNodeChildren(htmlVNodes as any)
const htmlElements: ReblendTyping.Component<P, S>[] = (await ElementUtil.createChildren(htmlVNodes as any)) as any
return htmlElements
}
async populateHtmlElements(): Promise<void> {
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: ReblendTyping.Component<P, S>[] = (await this.createInnerHtmlElements()) as any
htmlElements.forEach((node) => (node.directParent = this as any))
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 as 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 as any
}
placeholderElements.forEach((placeholderElement) => {
placeholderElement.directParent = this as any
placeholderElement.isPlaceholder = true
NodeOperationUtil.connected(placeholderElement)
})
requestAnimationFrame(() => {
/* empty */
})
}
})
} else {
import('./components/Placeholder').then(async ({ default: Placeholder }) => {
const placeholderVNodes = BaseComponent.construct(Placeholder as any, {
style: this.defaultReblendPlaceholderStyle,
})
const placeholderElements = await ElementUtil.createElement(placeholderVNodes as ReblendTyping.VNodeChild)
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 as any
}
placeholderElements.forEach((placeholderElement) => {
placeholderElement.directParent = this as any
placeholderElement.isPlaceholder = true
NodeOperationUtil.connected(placeholderElement)
})
requestAnimationFrame(() => {
/* empty */
})
}
})
}
return
}
NodeOperationUtil.connectedCallback(this as any)
}
addDisconnectedEffect(effect: () => void) {
this.disconnectEffects?.add(effect)
}
addStyle(style: string[] | IAny | string): void {
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: P) {
this.props = props || ({} as any)
}
componentDidMount() {
/* Optionally implement this in class component */
}
setState(value: S) {
this.state = value
this.onStateChange()
}
applyEffects() {
this.effectsFn?.forEach((effectFn) => {
effectFn()
})
}
handleError(error: Error) {
if (this.renderingErrorHandler) {
this.renderingErrorHandler(
(((error as any).component = this), error) as ReblendTyping.ReblendRenderingException<P, S>,
)
} 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: () => void) {
try {
fn.bind(this)()
} catch (error) {
this.handleError.bind(this)(error as 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: ReblendTyping.Patch<P, S>[] = []
let newVNodes: ReblendTyping.ReblendNode
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 as any]
}
newVNodes = DiffUtil.flattenVNodeChildren(newVNodes as ReblendTyping.VNodeChildren) as any
const oldNodes = [...(this.elementChildren?.values() || [])]
const maxLength = Math.max(oldNodes.length || 0, (newVNodes as ReblendTyping.VNodeChildren).length)
for (let i = 0; i < maxLength; i++) {
const newVNode: ReblendTyping.VNodeChild = newVNodes![i]
const currentVNode = oldNodes[i]
patches.push(...(NodeOperationUtil.diff(this as any, currentVNode as any, newVNode) as any))
}
}
} catch (error) {
this.handleError(error as Error)
} finally {
this.onStateChangeRunning = false
await NodeOperationUtil.applyPatches(patches)
if (this.numAwaitingUpdates) {
this.numAwaitingUpdates = 0
setTimeout(() => this.onStateChange(), 0)
}
newVNodes = null as any
}
}
async html(): Promise<ReblendTyping.ReblendNode> {
return null as any
}
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<P, S>(this as any, fromCleanUp)
}
cleanUp() {
/* Cleans up resources before the component unmounts. */
}
componentWillUnmount() {
/* Lifecycle method for component unmount actions. */
}
dependenciesChanged(currentDependencies: Array<any> | undefined, previousDependencies: Array<any> | undefined) {
if (!previousDependencies || previousDependencies.length !== currentDependencies?.length) {
return false
}
return currentDependencies.some((dep, index) => {
return !Object.is(dep, previousDependencies[index])
})
}
useState<T>(
initial: ReblendTyping.StateFunctionValue<T>,
...dependencyStringAndOrStateKey: string[]
): [T, ReblendTyping.StateFunction<T>] {
const stateID: string | undefined = dependencyStringAndOrStateKey.pop()
if (!stateID) {
throw stateIdNotIncluded
}
if (typeof initial === 'function') {
initial = (initial as () => T)()
this.state[stateID] = initial
} else if (initial instanceof Promise) {
initial.then((val) => (this.state[stateID] = val))
}
const variableSetter: ReblendTyping.StateFunction<T> = (async (
value: ReblendTyping.StateFunctionValue<T>,
force = false,
) => {
if (typeof value === 'function') {
value = await (value as (v: T) => T)(this.state[stateID])
} else if (value instanceof Promise) {
value = await value
}
if (force || this.state[stateID] !== value) {
this.state[stateID] = value as T
if (this.attached) {
Promise.resolve().then(() => this.onStateChange())
}
}
}).bind(this)
return [initial as T, variableSetter]
}
useEffect(
fn: ReblendTyping.StateEffectiveFunction,
dependencies: any[],
// eslint-disable-next-line @typescript-eslint/no-unused-vars
..._dependencyStringAndOrStateKey: string[]
) {
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: () => ReblendTyping.Primitive | Array<ReblendTyping.Primitive> = () => dep()
this.effectState[effectKey] = { cache: cacher(), cacher: cacher }
const internalFn = (() => {
const current = cacher()
if (
!dependencies ||
this.mountingEffects ||
this.dependenciesChanged(
current as ReblendTyping.Primitive[],
this.effectState[effectKey].cache as ReblendTyping.Primitive[],
)
) {
this.effectState[effectKey].cache = current
return fn()
}
}).bind(this)
this.effectsFn?.add(internalFn)
}
useReducer<T, I>(
reducer: ReblendTyping.StateReducerFunction<T, I>,
initial: ReblendTyping.StateFunctionValue<T>,
...dependencyStringAndOrStateKey: string[]
): [T, ReblendTyping.StateFunction<I>] {
reducer = reducer.bind(this)
const stateID: string | undefined = dependencyStringAndOrStateKey.pop()
if (!stateID) {
throw stateIdNotIncluded
}
const [state, setState] = this.useState<T>(initial, stateID)
this.state[stateID] = state
const fn: ReblendTyping.StateFunction<I> = (async (newValue: ReblendTyping.StateFunctionValue<I>) => {
let reducedVal: ReblendTyping.StateFunctionValue<T>
if (typeof newValue === 'function') {
reducedVal = await reducer(this.state[stateID], (newValue as (v: T) => I)(this.state[stateID]))
} else {
reducedVal = await reducer(this.state[stateID], newValue as any)
}
setState(reducedVal)
}).bind(this)
return [this.state[stateID], fn]
}
useMemo<T>(
fn: ReblendTyping.StateEffectiveMemoFunction<T>,
dependencies?: any[],
...dependencyStringAndOrStateKey: string[]
): T {
fn = fn.bind(this)
const stateID: string | undefined = dependencyStringAndOrStateKey.pop()
if (!stateID) {
throw stateIdNotIncluded
}
const [state, setState] = this.useState<T>(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: () => ReblendTyping.Primitive | Array<ReblendTyping.Primitive> = () => dep()
this.effectState[effectKey] = { cache: cacher(), cacher: cacher }
const internalFn = async () => {
const current = cacher()
if (
!dependencies ||
this.mountingEffects ||
this.dependenciesChanged(
current as ReblendTyping.Primitive[],
this.effectState[effectKey].cache as ReblendTyping.Primitive[],
)
) {
this.effectState[effectKey].cache = current
setState(fn())
}
}
this.effectsFn?.add(internalFn)
return this.state[stateID]
}
useRef<T>(initial: T, stateKey: string) {
const ref: ReblendTyping.Ref<T> = { stateKey, current: initial }
return ref
}
useCallback<T extends Function>(fn: T): T {
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 = {} as S
this.effectsFn = new Set()
this.disconnectEffects = new Set()
this.childrenPropsUpdate = new Set()
this.numAwaitingUpdates = 0
this.effectState = {}
this.hasDisconnected = false
}
}