reblendjs
Version:
ReblendJs uses Reactjs pradigm to build UI components, with isolated state for each components.
724 lines (665 loc) • 23.8 kB
text/typescript
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable @typescript-eslint/no-unused-expressions */
/* eslint-disable @typescript-eslint/no-explicit-any */
import { ChildrenPropsUpdateType, PatchTypeAndOrder } from 'reblend-typing'
import * as ReblendTyping from 'reblend-typing'
import { donotDeffer, getConfig, isCallable, replaceOrAddItemToList } from '../common/utils'
import { deepFlat } from './DiffUtil'
import {
isPrimitive,
isReblendPrimitiveElement,
isTextNode,
isEmpty,
isReblendRenderedNode,
isStandard,
isReactToReblendRenderedNode,
} from './NodeUtil'
import { setProps, removeProps } from './PropertyUtil'
import { createElement } from './ElementUtil'
import deepEqualIterative from 'reblend-deep-equal-iterative'
/**
* Detaches the given node from the DOM.
* If the node is a primitive, the function returns immediately.
* If the node has a `disconnectedCallback`, it will be invoked.
* Otherwise, it will be removed from the DOM.
*
* @param {ReblendTyping.Component<P, S> | HTMLElement} node - The node to detach.
*/
export async function detach<P, S>(node: ReblendTyping.Component<P, S> | HTMLElement) {
if (isPrimitive(node)) return
if ((node as ReblendTyping.Component<P, S>).disconnectedCallback) {
if (donotDeffer()) {
await (node as ReblendTyping.Component<P, S>).disconnectedCallback()
} else {
;(node as ReblendTyping.Component<P, S>).disconnectedCallback()
}
} else {
if (node.parentElement) {
node.outerHTML = ''
}
node.innerHTML = ''
node?.remove()
//detachChildren(node as any)
}
}
/**
* Detaches all child nodes and HTML elements from the given `HTMLElement/Component`.
* If the node is a primitive, the function returns immediately.
*
* @param {HTMLElement} node - The parent node from which children will be detached.
*/
export async function detachChildren<P, S>(node: HTMLElement) {
if (isPrimitive(node)) return
for (const child of new Set([
...node.childNodes,
...((node as ReblendTyping.Component<P, S>).elementChildren?.values() || []),
])) {
if (donotDeffer()) {
await detach(child as any)
} else {
detach(child as any)
}
}
}
/**
* Calls `connectedCallback` on the node if it exists, signaling that the node has been connected to the DOM.
*
* @template T
* @param {T | undefined} node - The node to connect.
*/
export async function connected<P, S, T extends ReblendTyping.Component<P, S> | HTMLElement>(node: T | undefined) {
if (!node) return
if ((node as ReblendTyping.Component<P, S>).connectedCallback) {
await (node as ReblendTyping.Component<P, S>).connectedCallback()
}
}
/**
* Replaces the old node with a new node or nodes.
* Handles scenarios where old and new nodes may be React-based or standard HTML.
*
* @param {ReblendTyping.Component<P, S> | ReblendTyping.Component<P, S>[]} newNode - The new node(s) to replace the old node.
* @param {ReblendTyping.Component<P, S>} oldNode - The old node to be replaced.
*/
export async function replaceOldNode<P, S>(
newNode: ReblendTyping.Component<P, S> | ReblendTyping.Component<P, S>[],
oldNode: ReblendTyping.Component<P, S>,
) {
let lastAttached = oldNode
if (!Array.isArray(newNode)) {
newNode = [newNode]
}
// Use DocumentFragment to batch insertions
const fragment = document.createDocumentFragment()
for (const node of newNode) {
if (oldNode.directParent && !oldNode.directParent.elementChildren?.has(node)) {
if (!oldNode.directParent.elementChildren) {
oldNode.directParent.elementChildren = new Set()
}
oldNode.directParent.elementChildren = replaceOrAddItemToList(
oldNode.directParent.elementChildren,
lastAttached,
node,
)
}
fragment.appendChild(node)
if (donotDeffer()) {
await connected(node)
} else {
connected(node)
}
lastAttached = node
}
// Insert the fragment after the oldNode
oldNode.after(fragment)
oldNode.remove()
oldNode.directParent?.elementChildren?.delete(oldNode)
/* requestAnimationFrame(() => { */
/* empty */
/* }) */
}
/**
* Creates patches to create or remove nodes by comparing oldNode and newNode.
*
* @param {ReblendTyping.Component<P, S>} parent - The parent node.
* @param {ReblendTyping.DomNodeChild} oldNode - The old node.
* @param {ReblendTyping.VNodeChild} newNode - The new node.
* @returns {ReblendTyping.Patch[]} - The array of patches.
*/
export function diffCreateOrRemove<P, S>(
parent: ReblendTyping.Component<P, S>,
oldNode: ReblendTyping.DomNodeChild<P, S>,
newNode: ReblendTyping.VNodeChild,
) {
const patches: ReblendTyping.Patch<P, S>[] = []
if (
!isPrimitive(oldNode) &&
Object.hasOwn(oldNode, 'reblendPrimitiveData') &&
(oldNode as ReblendTyping.ReblendPrimitive<P, S>).reblendPrimitiveData == newNode
) {
return []
}
if (!oldNode) {
patches.push({ type: PatchTypeAndOrder.CREATE, parent, newNode })
} else if (!newNode) {
patches.push({ type: PatchTypeAndOrder.REMOVE, parent, oldNode })
}
return patches
}
/**
* Diffs oldNode and newNode to generate patches that represent the changes between them.
*
* @param {ReblendTyping.Componennt<P, S>} origin - The rerendering origin node.
* @param {number} sessionId - The rerendering circle id.
* @param {ReblendTyping.DomNodeChild} oldNode - The old node.
* @param {ReblendTyping.VNodeChild} newNode - The new node.
* @returns {ReblendTyping.Patch[]} - The array of patches.
*/
export async function diff<P, S>(
origin: ReblendTyping.Component<P, S>,
sessionId: number,
parent: ReblendTyping.Component<P, S>,
oldNode: ReblendTyping.DomNodeChild<P, S>,
newNode: ReblendTyping.VNodeChild,
): Promise<ReblendTyping.Patch<P, S>[]> {
const patches: ReblendTyping.Patch<P, S>[] = []
if (!origin.renderingSessionTracker.isCurrentSession(sessionId)) {
return patches
}
if (isCallable(newNode)) {
newNode = await (newNode as any)()
}
if (isCallable(newNode)) {
patches.push({ type: PatchTypeAndOrder.REPLACE, parent, newNode, oldNode })
return patches
}
if (isPrimitive(oldNode) && isPrimitive(newNode)) {
patches.push({ type: PatchTypeAndOrder.CREATE, parent, newNode })
} else if (isPrimitive(oldNode) && !isPrimitive(newNode)) {
patches.push({ type: PatchTypeAndOrder.CREATE, parent, newNode })
} else if (isReblendPrimitiveElement(oldNode) && isPrimitive(newNode)) {
if ((<ReblendTyping.ReblendPrimitive<P, S>>oldNode).getData() !== newNode) {
;(<ReblendTyping.ReblendPrimitive<P, S>>oldNode).setData(newNode as ReblendTyping.Primitive)
}
} else if (isReblendPrimitiveElement(oldNode) && !isPrimitive(newNode)) {
patches.push({ type: PatchTypeAndOrder.REPLACE, parent, newNode, oldNode })
} else if (isTextNode(oldNode) && isPrimitive(newNode)) {
if (oldNode.textContent !== newNode) {
patches.push({ type: PatchTypeAndOrder.TEXT, newNode, oldNode })
}
} else if (isTextNode(oldNode) && !isPrimitive(newNode)) {
patches.push({ type: PatchTypeAndOrder.REPLACE, parent, newNode, oldNode })
} else if (!isReblendPrimitiveElement(oldNode) && isPrimitive(newNode)) {
patches.push({ type: PatchTypeAndOrder.REPLACE, parent, newNode, oldNode })
} else if (isEmpty(oldNode) && !isEmpty(newNode)) {
patches.push({ type: PatchTypeAndOrder.CREATE, parent, newNode })
} else if (!isEmpty(oldNode) && isEmpty(newNode)) {
patches.push({ type: PatchTypeAndOrder.REMOVE, parent, oldNode })
} else if ((oldNode?.props as any)?.key !== (newNode as any)?.props?.key) {
patches.push({ type: PatchTypeAndOrder.REPLACE, parent, newNode, oldNode })
} else if ('displayName' in oldNode && 'displayName' in (newNode as any)) {
const oldNodeTag = (oldNode.displayName as string).toLowerCase()
let newNodeTag = ''
const isReactComponent = (newNode as any)?.props?.reactcomponent
if (!isReactComponent && isCallable((newNode as any).displayName)) {
;(newNode as any).displayName = await (newNode as any).displayName()
}
if (isPrimitive((newNode as any).displayName)) {
newNodeTag = (newNode as ReblendTyping.VNode).displayName as string
} else if ((newNode as any).displayName.ELEMENT_NAME) {
newNodeTag = (newNode as any).displayName.ELEMENT_NAME
} else if ((newNode as any).displayName.displayName) {
newNodeTag = (newNode as any).displayName.displayName
} else if ((newNode as any).displayName.name) {
newNodeTag = (newNode as any).displayName.name
} else if ((newNode as any).displayName) {
newNodeTag = (newNode as any).displayName
}
newNodeTag = typeof newNodeTag === 'string' ? newNodeTag?.toLowerCase() : ''
if (
(!isReactComponent && isCallable((newNode as any).displayName)) ||
(oldNodeTag && newNodeTag && oldNodeTag !== newNodeTag)
) {
patches.push({ type: PatchTypeAndOrder.REPLACE, parent, newNode, oldNode })
} else {
const propsDiff = diffProps(newNode as ReblendTyping.VNode, oldNode)
if (propsDiff && propsDiff.length > 0) {
patches.push({
type: PatchTypeAndOrder.UPDATE,
patches: propsDiff,
})
}
if (!origin.renderingSessionTracker.isCurrentSession(sessionId)) {
return patches
}
if (oldNode.childrenInitialize) {
patches.push(...(await diffChildren(origin, sessionId, oldNode, oldNode, newNode as ReblendTyping.VNode)))
}
}
}
return patches
}
/**
* Diffs the props of the newNode and oldNode to generate a list of prop changes.
*
* @param {VNode} newNode - The new virtual node.
* @param {ReblendTyping.Component<P, S>} oldNode - The old base component node.
* @returns {any[]} - The array of property differences.
*/
export function diffProps<P, S>(newNode: ReblendTyping.VNode, oldNode: ReblendTyping.Component<P, S>) {
const ignoredProps = ['key', 'children', 'ref']
const patches: ReblendTyping.PropPatch<P, S>[] = []
const oldProps: ReblendTyping.IAny = oldNode?.props || {}
// Hack to get formatted props incase of alias name
// start hack
const dummyComponent: { props?: any } = {}
oldNode.initProps.call(dummyComponent, (newNode?.props || {}) as any)
const newProps: ReblendTyping.IAny = dummyComponent.props || {}
// end hack
const isReblendNode = isReblendRenderedNode(oldNode)
const diffConfig = getConfig().diffConfig || undefined
for (const key in newProps) {
if (!ignoredProps.includes(key) || (key === 'children' && isReblendNode)) {
let oldProp = oldProps[key]
let newProp = newProps[key]
const notEqual =
!deepEqualIterative(
oldProp,
newProp,
key === 'children' ? { ...diffConfig, depthThreshold: undefined, keyThreshold: undefined } : diffConfig,
) ||
(oldNode.displayName === 'select' && key === 'value' && oldNode['value'] !== newProp)
if (notEqual) {
oldProp = null
newProp = null
patches.push({
type: 'UPDATE',
node: oldNode,
key,
propValue: newProps[key],
})
}
}
}
for (const key in oldProps) {
if (!ignoredProps.includes(key) || (key === 'children' && isReblendNode)) {
if (!(key in newProps)) {
patches.push({
type: 'REMOVE',
node: oldNode,
key,
propValue: undefined,
})
}
}
}
return patches
}
/**
* Diffs the children of the old and new virtual nodes and returns the patches required to update them.
*
* @param {ReblendTyping.Component<P, S>} origin - The rerendering origin.
* @param {number} sessionId - The rerendering circle id.
* @param {ReblendTyping.Component<P, S>} parent - The parent component containing the children.
* @param {ReblendTyping.Component<P, S>} oldNode - The old component node.
* @param {VNode} newNode - The new virtual node.
* @returns {Patch[]} - An array of patches representing the differences between the old and new children.
*/
export async function diffChildren<P, S>(
origin: ReblendTyping.Component<P, S>,
sessionId: number,
parent: ReblendTyping.Component<P, S>,
oldNode: ReblendTyping.Component<P, S>,
newNode: ReblendTyping.VNode,
) {
if (!isStandard(oldNode) && !isReactToReblendRenderedNode(oldNode)) {
return []
}
const oldChildren: ReblendTyping.DomNodeChildren<P, S> = [...(oldNode.elementChildren?.values() || [])] as any
const newChildren: ReblendTyping.VNodeChildren = deepFlat(newNode?.props?.children || [])
const patches: ReblendTyping.Patch<P, S>[] = []
const maxLength = Math.max(oldChildren.length, newChildren.length)
for (let i = 0; i < maxLength; i++) {
const oldChild = oldChildren[i]
const newChild = newChildren[i]
/* if (isCallable(oldChild) || isCallable(newChild)) {
continue
} */
if (oldChild === undefined || newChild === undefined) {
patches.push(...diffCreateOrRemove(parent, oldChild, newChild))
} else {
patches.push(...(await diff(origin, sessionId, parent, oldChild, newChild)))
}
}
if (oldNode?.props) {
;(oldNode.props as any).children = newChildren
}
return patches
}
/**
* Applies an array of patches to the component.
*
* @param {Patch[]} patches - The array of patches to apply.
*/
export async function applyPatches<P, S>(patches: ReblendTyping.Patch<P, S>[]) {
const needsUpdate = new Set<ReblendTyping.Component<P, S>>()
patches = Array.from(patches || [] /* .sort((a, b) => a.type - b.type) */)
const configs = getConfig()
// Batching structures
const fragmentMap = new Map<any, DocumentFragment>()
const nodesToRemove: Array<HTMLElement | ReblendTyping.Component<P, S>> = []
const replaceOps: Array<{
oldNode: ReblendTyping.Component<P, S>
newNodeElements: Array<any>
}> = []
const afterPatchConnected: Array<() => Promise<void> | void> = []
const allPropPatches: ReblendTyping.PropPatch<P, S>[][] = []
for (const patch of patches) {
switch (patch.type) {
case PatchTypeAndOrder.CREATE: {
if (!patch.parent) break
const elements = await createElement(patch.newNode as ReblendTyping.VNode)
if (!elements.length) break
elements.forEach((element) => (element.directParent = patch.parent!))
if (!patch.parent.elementChildren) {
patch.parent.elementChildren = new Set(elements)
} else {
for (const element of elements) {
patch.parent.elementChildren.add(element)
}
}
if (isReactToReblendRenderedNode(patch.parent)) {
needsUpdate.add(patch.parent)
} else {
let fragment = fragmentMap.get(patch.parent)
if (!fragment) {
fragment = document.createDocumentFragment()
fragmentMap.set(patch.parent, fragment)
}
for (const node of elements) {
fragment.appendChild(node)
// Defer connected callbacks until after DOM insertion
afterPatchConnected.push(async () => {
if (configs.noDefering) {
await connected(node)
} else {
connected(node)
}
})
}
}
break
}
case PatchTypeAndOrder.REMOVE: {
if (patch.oldNode) {
removeRef(patch.oldNode)
nodesToRemove.push(patch.oldNode)
}
break
}
case PatchTypeAndOrder.REPLACE: {
if (patch.oldNode) {
removeRef(patch.oldNode)
const newNodeElements = await createElement(patch.newNode as ReblendTyping.VNode)
newNodeElements.forEach((element) => (element.directParent = patch.oldNode?.directParent as any))
replaceOps.push({ oldNode: patch.oldNode, newNodeElements })
}
break
}
case PatchTypeAndOrder.TEXT: {
patch.oldNode && (patch.oldNode.textContent = patch.newNode as string)
break
}
case PatchTypeAndOrder.UPDATE: {
if (patch.patches) {
allPropPatches.push(patch.patches)
}
break
}
}
}
// Batch all prop updates in a single call
if (allPropPatches.length > 0) {
if (configs.noDefering) {
await applyProps(allPropPatches)
} else {
applyProps(allPropPatches)
}
}
// Batch insertions
for (const [parent, fragment] of fragmentMap.entries()) {
parent.appendChild(fragment)
}
// Batch removals
for (const node of nodesToRemove) {
await replaceOperation(
node as any,
async () => {
/* empty */
},
true,
)
}
// Batch replacements
for (const { oldNode, newNodeElements } of replaceOps) {
await replaceOperation(oldNode, async () => {
for (const element of newNodeElements) {
element.directParent = oldNode.directParent as any
}
if (donotDeffer()) {
await replaceOldNode(newNodeElements as any, oldNode!)
} else {
replaceOldNode(newNodeElements as any, oldNode!)
}
})
}
// Run connected callbacks after DOM insertions
for (const fn of afterPatchConnected) {
await fn()
}
for (const parentUpdate of needsUpdate) {
if (isReactToReblendRenderedNode(parentUpdate)) {
if (configs.noDefering) {
parentUpdate.reactReblendMount && (await parentUpdate.reactReblendMount())
} else {
setTimeout(() => {
parentUpdate.reactReblendMount && parentUpdate.reactReblendMount()
}, configs.deferTimeout)
}
}
}
}
/**
* Asynchronously applies property patches to nodes.
*
* @param {PropPatch[]} [patches] - The property patches to apply.
*/
export async function applyProps<P, S>(patches?: ReblendTyping.PropPatch<P, S>[][]) {
const flatPatches: ReblendTyping.PropPatch<P, S>[] = Array.isArray(patches) ? patches.flat() : (patches as any) || []
const propUpdates = new Map<ReblendTyping.Component<P, S>, Record<string, any>>()
const propRemovals = new Map<ReblendTyping.Component<P, S>, Set<string>>()
const reactToReblendNodes: Set<ReblendTyping.Component<P, S>> = new Set()
const reblendNodesToUpdate: Set<ReblendTyping.Component<P, S>> = new Set()
const configs = getConfig()
flatPatches.forEach(({ type, node, key, propValue }) => {
if (type === 'UPDATE') {
if (!propUpdates.has(node)) propUpdates.set(node, {})
propUpdates.get(node)![key] = propValue
} else if (type === 'REMOVE') {
if (!propRemovals.has(node)) propRemovals.set(node, new Set())
propRemovals.get(node)!.add(key)
}
if (isReactToReblendRenderedNode(node)) {
if (key === 'children') {
node.childrenPropsUpdate?.add(ChildrenPropsUpdateType.CHILDREN)
} else {
node.childrenPropsUpdate?.add(ChildrenPropsUpdateType.NON_CHILDREN)
}
reactToReblendNodes.add(node)
} else if (isReblendRenderedNode(node) && node.attached) {
reblendNodesToUpdate.add(node)
}
})
// Batch setProps and removeProps for each node
for (const [node, props] of propUpdates.entries()) {
setProps(props, node, false)
}
for (const [node, keys] of propRemovals.entries()) {
const removeObj: Record<string, undefined> = {}
for (const key of keys) removeObj[key] = undefined
removeProps(removeObj, node)
}
// Batch checkPropsChange for React-to-Reblend nodes
for (const node of reactToReblendNodes) {
;(node as any)?.checkPropsChange()
}
// Batch onStateChange for Reblend nodes
if (reblendNodesToUpdate.size > 0) {
if (configs.noDefering) {
for (const node of reblendNodesToUpdate) {
await node.onStateChange()
}
} else {
setTimeout(() => {
for (const node of reblendNodesToUpdate) {
node.onStateChange()
}
}, configs.deferTimeout)
}
}
}
/**
* Performs a replacement operation on an old node.
*
* @param {ReblendTyping.Component<P, S>} oldNode - The old node to replace.
* @param {() => void} operation - The operation to execute for the replacement.
*/
export async function replaceOperation<P, S>(
oldNode: ReblendTyping.Component<P, S>,
operation: () => Promise<void>,
isRemoveOperation?: boolean,
) {
if (isRemoveOperation) {
oldNode.directParent?.elementChildren?.delete(oldNode)
oldNode.remove()
}
if (donotDeffer()) {
await operation()
await detach(oldNode)
} else {
operation().then(() =>
requestIdleCallback(() => {
detach(oldNode)
}),
)
}
}
export function removeRef(node: ReblendTyping.Component<any, any>) {
if (node.ref) {
if (typeof node.ref === 'function') {
node.ref(null as any)
} else if (node.ref.current) {
node.ref.current = null as any
}
}
}
/**
* Callback invoked when the component is connected to the DOM.
*/
export async function connectedCallback<P, S>(thiz: ReblendTyping.Component<P, S>) {
if (thiz.hasDisconnected) {
return
}
if (!thiz.attached) {
thiz.attached = true
await thiz.componentDidMount()
await thiz.mountEffects()
}
}
/**
* Lifecycle method called when the component is disconnected from the DOM.
* Cleans up resources and removes the component from its parent.
* Uses bruteforce approach insuring that there is not memory leakage
*/
export async function disconnectedCallback<P, S>(thiz: ReblendTyping.Component<P, S>, fromCleanUp = false) {
if (thiz.hasDisconnected) {
return
}
const configs = getConfig()
!fromCleanUp && (await thiz.cleanUp())
thiz.componentWillUnmount && (await thiz.componentWillUnmount())
/* if (thiz.ref) {
if (typeof thiz.ref === 'function') {
thiz.ref(null as any)
} else {
try {
thiz.ref.current = null as any
} catch {
// empty
}
}
} */
if (!isReblendPrimitiveElement(thiz)) {
for (const state of thiz.effectsState?.values() || []) {
state.disconnectEffect && (await state.disconnectEffect())
state.cache = null as any
state.cacher = null as any
state.effect = null as any
state.disconnectEffect = null as any
}
}
thiz.hookDisconnectedEffects?.forEach((destructor) => {
destructor()
})
if (configs.noDefering) {
await detachChildren(thiz as any)
} else {
detachChildren(thiz as any)
}
for (const node of thiz.elementChildren?.values() || []) {
if (configs.noDefering) {
await detach(node)
} else {
detach(node)
}
}
thiz.directParent?.elementChildren?.delete(thiz as any)
// Remove event listeners
// eslint-disable-next-line no-self-assign
thiz.innerHTML = ''
if (thiz.parentElement) {
// Remove event listeners
// eslint-disable-next-line no-self-assign
thiz.outerHTML = ''
try {
HTMLElement.prototype.remove.call(thiz)
} catch {
thiz.parentElement.removeChild(thiz)
}
}
if (configs.noDefering) {
await thiz.reactElementChildrenWrapper?.disconnectedCallback()
} else {
thiz.reactElementChildrenWrapper?.disconnectedCallback()
}
thiz.props = null as any
thiz.reactElementChildrenWrapper = null as any
thiz.elementChildren = null as any
thiz.effectsState = null as any
thiz.hookDisconnectedEffects = null as any
thiz.directParent = null as any
thiz.state = null as any
thiz.renderingError = null as any
thiz.renderingErrorHandler = null as any
thiz.nearestStandardParent = null as any
thiz.ReactClass = null as any
thiz.ref = null as any
thiz.childrenPropsUpdate = null as any
/* for (const property in thiz) {
if (thiz[property]) {
try {
thiz[property] = null
} catch {}
}
} */
thiz.hasDisconnected = true
}