Comparing version 0.0.35 to 0.0.36
@@ -11,3 +11,3 @@ declare global { | ||
ref?: ForgoRef<HTMLElement>; | ||
children?: ForgoNode[]; | ||
children?: ForgoNode | ForgoNode[]; | ||
}; | ||
@@ -22,8 +22,11 @@ export declare type ForgoComponentCtor<TProps extends ForgoElementProps> = (props: TProps) => ForgoComponent<TProps>; | ||
}; | ||
export declare type ForgoErrorArgs = { | ||
element: ForgoElementArg; | ||
export declare type ForgoErrorArgs = ForgoRenderArgs & { | ||
error: any; | ||
}; | ||
export declare type ForgoAfterRenderArgs = ForgoRenderArgs & { | ||
previousNode?: ChildNode; | ||
}; | ||
export declare type ForgoComponent<TProps extends ForgoElementProps> = { | ||
render: (props: TProps, args: ForgoRenderArgs) => ForgoNode; | ||
render: (props: TProps, args: ForgoRenderArgs) => ForgoNode | ForgoNode[]; | ||
afterRender?: (props: TProps, args: ForgoAfterRenderArgs) => void; | ||
error?: (props: TProps, args: ForgoErrorArgs) => ForgoNode; | ||
@@ -45,4 +48,8 @@ mount?: (props: TProps, args: ForgoRenderArgs) => void; | ||
}; | ||
export declare type ForgoElement<TProps extends ForgoElementProps> = ForgoDOMElement<TProps> | ForgoCustomComponentElement<TProps>; | ||
export declare type ForgoNode = string | number | boolean | object | null | BigInt | undefined | ForgoElement<any>; | ||
export declare type ForgoFragment = ForgoElementBase<any> & { | ||
type: typeof Fragment; | ||
}; | ||
export declare type ForgoElement<TProps extends ForgoElementProps> = ForgoDOMElement<TProps> | ForgoCustomComponentElement<TProps> | ForgoFragment; | ||
export declare type ForgoPrimitiveNode = string | number | boolean | object | null | BigInt | undefined; | ||
export declare type ForgoNode = ForgoPrimitiveNode | ForgoElement<any>; | ||
export declare type NodeAttachedComponentState<TProps> = { | ||
@@ -54,2 +61,3 @@ key?: any; | ||
args: ForgoRenderArgs; | ||
numNodes: number; | ||
}; | ||
@@ -68,9 +76,23 @@ export declare type NodeAttachedState = { | ||
export declare function setCustomEnv(value: any): void; | ||
export declare type DetachedNodeInsertionOptions = { | ||
type: "detached"; | ||
}; | ||
export declare type SearchableNodeInsertionOptions = { | ||
type: "search"; | ||
parentElement: HTMLElement; | ||
currentNodeIndex: number; | ||
length: number; | ||
}; | ||
export declare type NodeInsertionOptions = DetachedNodeInsertionOptions | SearchableNodeInsertionOptions; | ||
export declare type RenderResult = { | ||
nodes: ChildNode[]; | ||
}; | ||
export declare const Fragment: unique symbol; | ||
export declare function mount(forgoNode: ForgoNode, container: HTMLElement | string | null): void; | ||
export declare function render(forgoNode: ForgoNode): { | ||
node: ChildNode; | ||
boundary?: ForgoComponent<any> | undefined; | ||
nodes: ChildNode[]; | ||
}; | ||
export declare function rerender(element: ForgoElementArg | undefined, props?: undefined, fullRerender?: boolean): void; | ||
export declare function rerender(element: ForgoElementArg | undefined, props?: undefined, fullRerender?: boolean): RenderResult; | ||
export declare function getForgoState(node: ChildNode): NodeAttachedState | undefined; | ||
export declare function setForgoState(node: ChildNode, state: NodeAttachedState): void; |
"use strict"; | ||
Object.defineProperty(exports, "__esModule", { value: true }); | ||
exports.setForgoState = exports.getForgoState = exports.rerender = exports.render = exports.mount = exports.setCustomEnv = void 0; | ||
exports.setForgoState = exports.getForgoState = exports.rerender = exports.render = exports.mount = exports.Fragment = exports.setCustomEnv = void 0; | ||
/* | ||
@@ -23,5 +23,10 @@ The element types we care about. | ||
/* | ||
Fragment constructor. | ||
We simply use it as a marker in jsx-runtime. | ||
*/ | ||
exports.Fragment = Symbol("FORGO_FRAGMENT"); | ||
/* | ||
This is the main render function. | ||
forgoNode is the node to render. | ||
node is an existing node to which the element needs to be rendered (if rerendering) | ||
@@ -32,15 +37,21 @@ May not always be passed in, like for first render or when no compatible node exists. | ||
*/ | ||
function internalRender(forgoNode, targetNode, pendingAttachStates, fullRerender, boundary) { | ||
// Just a string | ||
if (!isForgoElement(forgoNode)) { | ||
return renderString(stringOfPrimitiveNode(forgoNode), targetNode, pendingAttachStates, fullRerender); | ||
function internalRender(forgoNode, nodeInsertionOptions, pendingAttachStates) { | ||
// Array of Nodes | ||
if (Array.isArray(forgoNode)) { | ||
return renderArray(forgoNode, nodeInsertionOptions, pendingAttachStates); | ||
} | ||
// Primitive Nodes | ||
else if (!isForgoElement(forgoNode)) { | ||
return renderText(forgoNode, nodeInsertionOptions, pendingAttachStates); | ||
} | ||
// HTML Element | ||
else if (isForgoDOMElement(forgoNode)) { | ||
return renderDOMElement(forgoNode, targetNode, pendingAttachStates, fullRerender, boundary); | ||
return renderDOMElement(forgoNode, nodeInsertionOptions, pendingAttachStates); | ||
} | ||
else if (isForgoFragment(forgoNode)) { | ||
return renderFragment(forgoNode, nodeInsertionOptions, pendingAttachStates); | ||
} | ||
// Custom Component. | ||
// We don't renderChildren since that is the CustomComponent's prerogative. | ||
else { | ||
return renderCustomComponent(forgoNode, targetNode, pendingAttachStates, fullRerender, boundary); | ||
return renderCustomComponent(forgoNode, nodeInsertionOptions, pendingAttachStates); | ||
} | ||
@@ -50,3 +61,3 @@ } | ||
Render a string. | ||
Such as in the render function below: | ||
@@ -61,30 +72,47 @@ function MyComponent() { | ||
*/ | ||
function renderString(text, targetNode, pendingAttachStates, fullRerender) { | ||
var _a; | ||
function renderText(forgoNode, nodeInsertionOptions, pendingAttachStates) { | ||
// Text nodes will always be recreated | ||
const textNode = env.document.createTextNode(text); | ||
if (targetNode) { | ||
// We have to get oldStates before attachProps; | ||
// coz attachProps will overwrite with new states. | ||
const oldComponentStates = (_a = getForgoState(targetNode)) === null || _a === void 0 ? void 0 : _a.components; | ||
attachProps(text, textNode, pendingAttachStates); | ||
if (oldComponentStates) { | ||
const indexOfFirstIncompatibleState = findIndexOfFirstIncompatibleState(pendingAttachStates, oldComponentStates); | ||
unmountComponents(oldComponentStates, indexOfFirstIncompatibleState); | ||
mountComponents(pendingAttachStates, indexOfFirstIncompatibleState); | ||
const textNode = env.document.createTextNode(stringOfPrimitiveNode(forgoNode)); | ||
// We need to create a detached node | ||
if (nodeInsertionOptions.type === "detached") { | ||
syncStateAndProps(forgoNode, textNode, textNode, pendingAttachStates); | ||
return { nodes: [textNode] }; | ||
} | ||
// We have to find a node to replace. | ||
else { | ||
// If we're searching in a list, we replace if the current node is a text node. | ||
const childNodes = nodeInsertionOptions.parentElement.childNodes; | ||
if (nodeInsertionOptions.length) { | ||
let targetNode = childNodes[nodeInsertionOptions.currentNodeIndex]; | ||
if (targetNode.nodeType === TEXT_NODE_TYPE) { | ||
syncStateAndProps(forgoNode, textNode, targetNode, pendingAttachStates); | ||
targetNode.replaceWith(textNode); | ||
return { nodes: [textNode] }; | ||
} | ||
else { | ||
syncStateAndProps(forgoNode, textNode, textNode, pendingAttachStates); | ||
const nextNode = childNodes[nodeInsertionOptions.currentNodeIndex]; | ||
nodeInsertionOptions.parentElement.insertBefore(textNode, nextNode); | ||
return { nodes: [textNode] }; | ||
} | ||
} | ||
// There are no target nodes available | ||
else { | ||
mountComponents(pendingAttachStates, 0); | ||
const childNodes = nodeInsertionOptions.parentElement.childNodes; | ||
syncStateAndProps(forgoNode, textNode, textNode, pendingAttachStates); | ||
if (childNodes.length === 0 || | ||
nodeInsertionOptions.currentNodeIndex === 0) { | ||
nodeInsertionOptions.parentElement.prepend(textNode); | ||
} | ||
else { | ||
const nextNode = childNodes[nodeInsertionOptions.currentNodeIndex]; | ||
nodeInsertionOptions.parentElement.insertBefore(textNode, nextNode); | ||
} | ||
return { nodes: [textNode] }; | ||
} | ||
targetNode.replaceWith(textNode); | ||
} | ||
else { | ||
attachProps(text, textNode, pendingAttachStates); | ||
mountComponents(pendingAttachStates, 0); | ||
} | ||
return { node: textNode }; | ||
} | ||
/* | ||
Render a DOM element. | ||
Such as in the render function below: | ||
@@ -99,34 +127,53 @@ function MyComponent() { | ||
*/ | ||
function renderDOMElement(forgoElement, targetNode, pendingAttachStates, fullRerender, boundary) { | ||
var _a; | ||
if (targetNode) { | ||
let nodeToBindTo; | ||
// if the nodes are not of the same of the same type, we need to replace. | ||
if (targetNode.nodeType === TEXT_NODE_TYPE || | ||
(targetNode.tagName && | ||
targetNode.tagName.toLowerCase() !== forgoElement.type)) { | ||
const newElement = env.document.createElement(forgoElement.type); | ||
targetNode.replaceWith(newElement); | ||
nodeToBindTo = newElement; | ||
function renderDOMElement(forgoElement, nodeInsertionOptions, pendingAttachStates) { | ||
// We need to create a detached node | ||
if (nodeInsertionOptions.type === "detached") { | ||
let newElement = env.document.createElement(forgoElement.type); | ||
syncStateAndProps(forgoElement, newElement, newElement, pendingAttachStates); | ||
renderDOMElementChildNodes(newElement); | ||
return { nodes: [newElement] }; | ||
} | ||
// We have to find a node to replace. | ||
else { | ||
const childNodes = nodeInsertionOptions.parentElement.childNodes; | ||
if (nodeInsertionOptions.length) { | ||
const searchResult = findReplacementCandidateForDOMElement(forgoElement, childNodes, nodeInsertionOptions.currentNodeIndex, nodeInsertionOptions.length); | ||
if (searchResult.found) { | ||
// Get rid of unwanted nodes. | ||
unloadNodes(Array.from(childNodes).slice(nodeInsertionOptions.currentNodeIndex, searchResult.index)); | ||
const targetNode = childNodes[searchResult.index]; | ||
syncStateAndProps(forgoElement, targetNode, targetNode, pendingAttachStates); | ||
renderDOMElementChildNodes(targetNode); | ||
return { nodes: [targetNode] }; | ||
} | ||
else { | ||
const newElement = addNewDOMElement(nodeInsertionOptions.parentElement, childNodes[nodeInsertionOptions.currentNodeIndex]); | ||
return { nodes: [newElement] }; | ||
} | ||
} | ||
else { | ||
nodeToBindTo = targetNode; | ||
const newElement = addNewDOMElement(nodeInsertionOptions.parentElement, childNodes[nodeInsertionOptions.currentNodeIndex]); | ||
return { nodes: [newElement] }; | ||
} | ||
// We have to get oldStates before attachProps; | ||
// coz attachProps will overwrite with new states. | ||
const oldComponentStates = (_a = getForgoState(targetNode)) === null || _a === void 0 ? void 0 : _a.components; | ||
attachProps(forgoElement, nodeToBindTo, pendingAttachStates); | ||
if (oldComponentStates) { | ||
const indexOfFirstIncompatibleState = findIndexOfFirstIncompatibleState(pendingAttachStates, oldComponentStates); | ||
unmountComponents(oldComponentStates, indexOfFirstIncompatibleState); | ||
mountComponents(pendingAttachStates, indexOfFirstIncompatibleState); | ||
} | ||
function renderDOMElementChildNodes(parentElement) { | ||
const forgoChildrenObj = forgoElement.props.children; | ||
// Children will not be an array if single item | ||
const forgoChildren = flatten((Array.isArray(forgoChildrenObj) | ||
? forgoChildrenObj | ||
: [forgoChildrenObj]).filter((x) => typeof x !== "undefined" && x !== null)); | ||
let currentChildNodeIndex = 0; | ||
for (const forgoChild of forgoChildren) { | ||
const { nodes } = internalRender(forgoChild, { | ||
type: "search", | ||
parentElement, | ||
currentNodeIndex: currentChildNodeIndex, | ||
length: parentElement.childNodes.length - currentChildNodeIndex, | ||
}, []); | ||
currentChildNodeIndex += nodes.length; | ||
} | ||
else { | ||
mountComponents(pendingAttachStates, 0); | ||
} | ||
renderChildNodes(forgoElement, nodeToBindTo, fullRerender, boundary); | ||
return { node: nodeToBindTo }; | ||
// Get rid the the remaining nodes | ||
unloadNodes(Array.from(parentElement.childNodes).slice(currentChildNodeIndex)); | ||
} | ||
// There was no node passed in; have to create a new element. | ||
else { | ||
function addNewDOMElement(parentElement, oldNode) { | ||
const newElement = env.document.createElement(forgoElement.type); | ||
@@ -136,23 +183,8 @@ if (forgoElement.props.ref) { | ||
} | ||
attachProps(forgoElement, newElement, pendingAttachStates); | ||
mountComponents(pendingAttachStates, 0); | ||
renderChildNodes(forgoElement, newElement, fullRerender, boundary); | ||
return { node: newElement }; | ||
parentElement.insertBefore(newElement, oldNode); | ||
syncStateAndProps(forgoElement, newElement, newElement, pendingAttachStates); | ||
renderDOMElementChildNodes(newElement); | ||
return newElement; | ||
} | ||
} | ||
function boundaryFallback(targetNode, props, args, statesToAttach, fullRerender, boundary, exec) { | ||
try { | ||
return exec(); | ||
} | ||
catch (error) { | ||
if (boundary && boundary.error) { | ||
const errorArgs = Object.assign(Object.assign({}, args), { error }); | ||
const newForgoElement = boundary.error(props, errorArgs); | ||
return internalRender(newForgoElement, targetNode, statesToAttach, fullRerender, boundary); | ||
} | ||
else { | ||
throw error; | ||
} | ||
} | ||
} | ||
/* | ||
@@ -162,71 +194,80 @@ Render a Custom Component | ||
*/ | ||
function renderCustomComponent(forgoElement, targetNode, pendingAttachStates, fullRerender, boundary) { | ||
if (targetNode) { | ||
const state = getExistingForgoState(targetNode); | ||
const componentIndex = pendingAttachStates.length; | ||
const componentState = state.components[componentIndex]; | ||
const haveCompatibleState = componentState && componentState.ctor === forgoElement.type; | ||
// We have compatible state, and this is a rerender | ||
if (haveCompatibleState) { | ||
if (fullRerender || | ||
havePropsChanged(forgoElement.props, componentState.props)) { | ||
if (!componentState.component.shouldUpdate || | ||
componentState.component.shouldUpdate(forgoElement.props, componentState.props)) { | ||
// Since we have compatible state already stored, | ||
// we'll push the savedComponentState into pending states for later attachment. | ||
const statesToAttach = pendingAttachStates.concat(Object.assign(Object.assign({}, componentState), { props: forgoElement.props })); | ||
// Get a new element by calling render on existing component. | ||
const newForgoElement = componentState.component.render(forgoElement.props, componentState.args); | ||
return boundaryFallback(targetNode, forgoElement.props, componentState.args, statesToAttach, fullRerender, boundary, () => { | ||
// Pass it on for rendering... | ||
return internalRender(newForgoElement, targetNode, statesToAttach, fullRerender, boundary); | ||
}); | ||
function renderCustomComponent(forgoElement, nodeInsertionOptions, pendingAttachStates | ||
// boundary: ForgoComponent<any> | undefined | ||
) { | ||
const componentIndex = pendingAttachStates.length; | ||
// We need to create a detached node | ||
if (nodeInsertionOptions.type === "detached") { | ||
return addNewComponent(); | ||
} | ||
// We have to find a node to replace. | ||
else { | ||
if (nodeInsertionOptions.length) { | ||
const childNodes = nodeInsertionOptions.parentElement.childNodes; | ||
const searchResult = findReplacementCandidateForCustomComponent(forgoElement, childNodes, nodeInsertionOptions.currentNodeIndex, nodeInsertionOptions.length); | ||
if (searchResult.found) { | ||
// Get rid of unwanted nodes. | ||
unloadNodes(Array.from(childNodes).slice(nodeInsertionOptions.currentNodeIndex, searchResult.index)); | ||
const targetNode = childNodes[nodeInsertionOptions.currentNodeIndex]; | ||
const state = getExistingForgoState(targetNode); | ||
const componentState = state.components[componentIndex]; | ||
const haveCompatibleState = componentState && componentState.ctor === forgoElement.type; | ||
if (haveCompatibleState) { | ||
return renderExistingComponent(nodeInsertionOptions, componentState); | ||
} | ||
// shouldUpdate() returned false | ||
else { | ||
return { node: targetNode, boundary }; | ||
return addNewComponent(); | ||
} | ||
} | ||
// not a fullRender and havePropsChanged() returned false | ||
// No matching node found | ||
else { | ||
return { node: targetNode, boundary }; | ||
return addNewComponent(); | ||
} | ||
} | ||
// We don't have compatible state, have to create a new component. | ||
// No nodes in target node list | ||
else { | ||
const args = { element: { componentIndex } }; | ||
const ctor = forgoElement.type; | ||
const component = ctor(forgoElement.props); | ||
assertIsComponent(ctor, component); | ||
boundary = component.error ? component : boundary; | ||
// Create new component state | ||
// ... and push it to pendingAttachStates | ||
const newComponentState = { | ||
key: forgoElement.key, | ||
ctor, | ||
component, | ||
props: forgoElement.props, | ||
args, | ||
}; | ||
return addNewComponent(); | ||
} | ||
} | ||
function renderExistingComponent(nodeInsertionOptions, componentState) { | ||
if (!componentState.component.shouldUpdate || | ||
componentState.component.shouldUpdate(forgoElement.props, componentState.props)) { | ||
// Since we have compatible state already stored, | ||
// we'll push the savedComponentState into pending states for later attachment. | ||
const newComponentState = Object.assign(Object.assign({}, componentState), { props: forgoElement.props }); | ||
const statesToAttach = pendingAttachStates.concat(newComponentState); | ||
return boundaryFallback(targetNode, forgoElement.props, args, statesToAttach, fullRerender, boundary, () => { | ||
// Create an element by rendering the component | ||
const newForgoElement = component.render(forgoElement.props, args); | ||
// Pass it on for rendering... | ||
return internalRender(newForgoElement, targetNode, statesToAttach, fullRerender, boundary); | ||
// Get a new element by calling render on existing component. | ||
const newForgoNode = newComponentState.component.render(forgoElement.props, newComponentState.args); | ||
const boundary = newComponentState.component.error | ||
? newComponentState.component | ||
: undefined; | ||
return withErrorBoundary(forgoElement.props, newComponentState.args, statesToAttach, boundary, () => { | ||
// Create new node insertion options. | ||
const insertionOptions = { | ||
type: "search", | ||
currentNodeIndex: nodeInsertionOptions.currentNodeIndex, | ||
length: newComponentState.numNodes, | ||
parentElement: nodeInsertionOptions.parentElement, | ||
}; | ||
return renderComponentAndRemoveStaleNodes(newForgoNode, insertionOptions, statesToAttach, newComponentState); | ||
}); | ||
} | ||
// shouldUpdate() returned false | ||
else { | ||
const allNodes = Array.from(nodeInsertionOptions.parentElement.childNodes); | ||
const indexOfNode = allNodes.findIndex((x) => x === componentState.args.element.node); | ||
return { | ||
nodes: allNodes.slice(indexOfNode, indexOfNode + componentState.numNodes), | ||
}; | ||
} | ||
} | ||
// First time render | ||
else { | ||
const args = { | ||
element: { componentIndex: pendingAttachStates.length }, | ||
}; | ||
function addNewComponent() { | ||
const args = { element: { componentIndex } }; | ||
const ctor = forgoElement.type; | ||
const component = ctor(forgoElement.props); | ||
assertIsComponent(ctor, component); | ||
boundary = component.error ? component : boundary; | ||
// We'll have to create a new component state | ||
const boundary = component.error ? component : undefined; | ||
// Create new component state | ||
// ... and push it to pendingAttachStates | ||
const componentState = { | ||
const newComponentState = { | ||
key: forgoElement.key, | ||
@@ -237,102 +278,112 @@ ctor, | ||
args, | ||
numNodes: 0, | ||
}; | ||
const statesToAttach = pendingAttachStates.concat(componentState); | ||
// We have no node to render to yet. So pass undefined for the node. | ||
return boundaryFallback(undefined, forgoElement.props, args, statesToAttach, fullRerender, boundary, () => { | ||
const statesToAttach = pendingAttachStates.concat(newComponentState); | ||
return withErrorBoundary(forgoElement.props, args, statesToAttach, boundary, () => { | ||
// Create an element by rendering the component | ||
const newForgoElement = component.render(forgoElement.props, args); | ||
// Create new node insertion options. | ||
const insertionOptions = nodeInsertionOptions.type === "detached" | ||
? nodeInsertionOptions | ||
: { | ||
type: "search", | ||
currentNodeIndex: nodeInsertionOptions.currentNodeIndex, | ||
length: 0, | ||
parentElement: nodeInsertionOptions.parentElement, | ||
}; | ||
// Pass it on for rendering... | ||
return internalRender(newForgoElement, undefined, statesToAttach, fullRerender, boundary); | ||
const renderResult = internalRender(newForgoElement, insertionOptions, statesToAttach); | ||
// In case we rendered an array, set the node to the first node. | ||
// And set numNodes. | ||
newComponentState.numNodes = renderResult.nodes.length; | ||
if (renderResult.nodes.length > 1) { | ||
newComponentState.args.element.node = renderResult.nodes[0]; | ||
} | ||
return renderResult; | ||
}); | ||
} | ||
} | ||
/* | ||
Loop through and render child nodes of a forgo DOM element. | ||
In the following example, if the forgoElement represents the 'parent' node, render the child nodes. | ||
eg: | ||
<div id="parent"> | ||
<MyTopBar /> | ||
<p id="first-child">some content goes here...</p> | ||
<MyFooter /> | ||
</div> | ||
The parentElement is the actual DOM element which corresponds to forgoElement. | ||
*/ | ||
function renderChildNodes(forgoParentElement, parentElement, fullRerender, boundary) { | ||
const { children: forgoChildrenObj } = forgoParentElement.props; | ||
const childNodes = parentElement.childNodes; | ||
// Children will not be an array if single item | ||
const forgoChildren = flatten((Array.isArray(forgoChildrenObj) | ||
? forgoChildrenObj | ||
: [forgoChildrenObj]).filter((x) => typeof x !== "undefined" && x !== null)); | ||
let forgoChildIndex = 0; | ||
let numNodesCreated = 0; | ||
if (forgoChildren) { | ||
for (forgoChildIndex = 0; forgoChildIndex < forgoChildren.length; forgoChildIndex++) { | ||
const forgoChild = forgoChildren[forgoChildIndex]; | ||
// This is a primitive node, such as string | number etc. | ||
if (!isForgoElement(forgoChild)) { | ||
// If the first node is a text node, we could pass that along. | ||
// No need to replace here, callee does that. | ||
if (childNodes[forgoChildIndex] && | ||
childNodes[forgoChildIndex].nodeType === TEXT_NODE_TYPE) { | ||
renderString(stringOfPrimitiveNode(forgoChild), childNodes[forgoChildIndex], [], fullRerender); | ||
} | ||
// But otherwise, don't pass a replacement node. Just insert instead. | ||
else { | ||
const { node } = renderString(stringOfPrimitiveNode(forgoChild), undefined, [], fullRerender); | ||
if (childNodes.length > forgoChildIndex) { | ||
parentElement.insertBefore(node, childNodes[forgoChildIndex]); | ||
} | ||
else { | ||
parentElement.appendChild(node); | ||
} | ||
} | ||
// We have added one node. | ||
numNodesCreated++; | ||
function withErrorBoundary(props, args, statesToAttach, boundary, exec) { | ||
try { | ||
return exec(); | ||
} | ||
catch (error) { | ||
if (boundary && boundary.error) { | ||
const errorArgs = Object.assign(Object.assign({}, args), { error }); | ||
const newForgoElement = boundary.error(props, errorArgs); | ||
return internalRender(newForgoElement, nodeInsertionOptions, statesToAttach); | ||
} | ||
// This is a Forgo DOM Element | ||
else if (isForgoDOMElement(forgoChild)) { | ||
const findResult = findReplacementCandidateForDOMElement(forgoChild, childNodes, forgoChildIndex); | ||
if (findResult.found) { | ||
const nodesToRemove = Array.from(childNodes).slice(forgoChildIndex, findResult.index); | ||
unloadNodes(nodesToRemove); | ||
renderDOMElement(forgoChild, childNodes[forgoChildIndex], [], fullRerender); | ||
} | ||
else { | ||
const { node } = internalRender(forgoChild, undefined, [], fullRerender); | ||
if (childNodes.length > forgoChildIndex) { | ||
parentElement.insertBefore(node, childNodes[forgoChildIndex]); | ||
} | ||
else { | ||
parentElement.appendChild(node); | ||
} | ||
} | ||
} | ||
// This is a Custom Component | ||
else { | ||
const findResult = findReplacementCandidateForCustomComponent(forgoChild, childNodes, forgoChildIndex); | ||
if (findResult.found) { | ||
const nodesToRemove = Array.from(childNodes).slice(forgoChildIndex, findResult.index); | ||
unloadNodes(nodesToRemove); | ||
internalRender(forgoChild, childNodes[forgoChildIndex], [], fullRerender); | ||
} | ||
else { | ||
const { node } = internalRender(forgoChild, undefined, [], fullRerender); | ||
if (childNodes.length > forgoChildIndex) { | ||
parentElement.insertBefore(node, childNodes[forgoChildIndex]); | ||
} | ||
else { | ||
parentElement.appendChild(node); | ||
} | ||
} | ||
throw error; | ||
} | ||
} | ||
} | ||
// Now we gotta remove old nodes which aren't being used. | ||
// Everything after forgoChildIndex must go. | ||
const nodesToRemove = Array.from(childNodes).slice(forgoChildIndex); | ||
} | ||
function renderComponentAndRemoveStaleNodes(forgoNode, insertionOptions, statesToAttach, componentState) { | ||
const totalNodesBeforeRender = insertionOptions.parentElement.childNodes.length; | ||
// Pass it on for rendering... | ||
const renderResult = internalRender(forgoNode, insertionOptions, statesToAttach); | ||
const totalNodesAfterRender = insertionOptions.parentElement.childNodes.length; | ||
const numNodesRemoved = totalNodesBeforeRender + renderResult.nodes.length - totalNodesAfterRender; | ||
const newIndex = insertionOptions.currentNodeIndex + renderResult.nodes.length; | ||
const nodesToRemove = Array.from(insertionOptions.parentElement.childNodes).slice(newIndex, newIndex + componentState.numNodes - numNodesRemoved); | ||
unloadNodes(nodesToRemove); | ||
// In case we rendered an array, set the node to the first node. | ||
// And set numNodes. | ||
componentState.numNodes = renderResult.nodes.length; | ||
if (renderResult.nodes.length > 1) { | ||
componentState.args.element.node = renderResult.nodes[0]; | ||
} | ||
return renderResult; | ||
} | ||
/* | ||
Render an array of components | ||
Called when a CustomComponent returns an array (or fragment) in its render method. | ||
*/ | ||
function renderArray(forgoNodes, nodeInsertionOptions, pendingAttachStates) { | ||
const flattenedNodes = flatten(forgoNodes); | ||
if (nodeInsertionOptions.type === "detached") { | ||
throw new Error("Arrays and fragments cannot be rendered at the top level."); | ||
} | ||
else { | ||
let allNodes = []; | ||
let currentNodeIndex = nodeInsertionOptions.currentNodeIndex; | ||
let numNodes = nodeInsertionOptions.length; | ||
for (const forgoNode of flattenedNodes) { | ||
const totalNodesBeforeRender = nodeInsertionOptions.parentElement.childNodes.length; | ||
const insertionOptions = Object.assign(Object.assign({}, nodeInsertionOptions), { currentNodeIndex, length: numNodes }); | ||
const { nodes } = internalRender(forgoNode, insertionOptions, pendingAttachStates); | ||
const totalNodesAfterRender = nodeInsertionOptions.parentElement.childNodes.length; | ||
const numNodesRemoved = totalNodesBeforeRender + nodes.length - totalNodesAfterRender; | ||
currentNodeIndex += nodes.length; | ||
numNodes -= numNodesRemoved; | ||
allNodes = allNodes.concat(nodes); | ||
} | ||
return { nodes: allNodes }; | ||
} | ||
} | ||
/* | ||
Render a Fragment | ||
*/ | ||
function renderFragment(fragment, nodeInsertionOptions, pendingAttachStates) { | ||
return renderArray(flatten(fragment), nodeInsertionOptions, pendingAttachStates); | ||
} | ||
/* | ||
Sync component states and props between a newNode and an oldNode. | ||
*/ | ||
function syncStateAndProps(forgoNode, newNode, targetNode, pendingAttachStates) { | ||
var _a; | ||
// We have to get oldStates before attachProps; | ||
// coz attachProps will overwrite with new states. | ||
const oldComponentStates = (_a = getForgoState(targetNode)) === null || _a === void 0 ? void 0 : _a.components; | ||
attachProps(forgoNode, newNode, pendingAttachStates); | ||
if (oldComponentStates) { | ||
const indexOfFirstIncompatibleState = findIndexOfFirstIncompatibleState(pendingAttachStates, oldComponentStates); | ||
unmountComponents(oldComponentStates, indexOfFirstIncompatibleState); | ||
mountComponents(pendingAttachStates, indexOfFirstIncompatibleState); | ||
} | ||
else { | ||
mountComponents(pendingAttachStates, 0); | ||
} | ||
} | ||
/* | ||
Unloads components from a node list | ||
@@ -345,3 +396,2 @@ This does: | ||
for (const node of nodes) { | ||
node.remove(); | ||
const state = getForgoState(node); | ||
@@ -351,2 +401,3 @@ if (state) { | ||
} | ||
node.remove(); | ||
} | ||
@@ -379,15 +430,25 @@ } | ||
} | ||
function mountComponents(states, from) { | ||
/* | ||
Unmount components above an index. | ||
This is going to be passed a stale state[] | ||
The from param is the index at which stale state[] differs from new state[] | ||
*/ | ||
function unmountComponents(states, from) { | ||
for (let j = from; j < states.length; j++) { | ||
const state = states[j]; | ||
if (state.component.mount) { | ||
state.component.mount(state.props, state.args); | ||
if (state.component.unmount) { | ||
state.component.unmount(state.props, state.args); | ||
} | ||
} | ||
} | ||
function unmountComponents(states, from) { | ||
/* | ||
Mount components above an index. | ||
This is going to be passed the new state[]. | ||
The from param is the index at which stale state[] differs from new state[] | ||
*/ | ||
function mountComponents(states, from) { | ||
for (let j = from; j < states.length; j++) { | ||
const state = states[j]; | ||
if (state.component.unmount) { | ||
state.component.unmount(state.props, state.args); | ||
if (state.component.mount) { | ||
state.component.mount(state.props, state.args); | ||
} | ||
@@ -402,4 +463,4 @@ } | ||
*/ | ||
function findReplacementCandidateForDOMElement(forgoElement, nodes, searchNodesFrom) { | ||
for (let i = searchNodesFrom; i < nodes.length; i++) { | ||
function findReplacementCandidateForDOMElement(forgoElement, nodes, searchFrom, length) { | ||
for (let i = searchFrom; i < searchFrom + length; i++) { | ||
const node = nodes[i]; | ||
@@ -429,4 +490,4 @@ if (forgoElement.key) { | ||
*/ | ||
function findReplacementCandidateForCustomComponent(forgoElement, nodes, searchNodesFrom) { | ||
for (let i = searchNodesFrom; i < nodes.length; i++) { | ||
function findReplacementCandidateForCustomComponent(forgoElement, nodes, searchFrom, length) { | ||
for (let i = searchFrom; i < searchFrom + length; i++) { | ||
const node = nodes[i]; | ||
@@ -454,2 +515,4 @@ const stateOnNode = getForgoState(node); | ||
function attachProps(forgoNode, node, pendingAttachStates) { | ||
// Capture previous nodes if afterRender is defined; | ||
const previousNodes = []; | ||
// We have to inject node into the args object. | ||
@@ -459,2 +522,3 @@ // components are already holding a reference to the args object. | ||
for (const state of pendingAttachStates) { | ||
previousNodes.push(state.component.afterRender ? state.args.element.node : undefined); | ||
state.args.element.node = node; | ||
@@ -496,26 +560,30 @@ } | ||
} | ||
// Run afterRender() is defined. | ||
previousNodes.forEach((previousNode, i) => { | ||
const state = pendingAttachStates[i]; | ||
if (state.component.afterRender) { | ||
state.component.afterRender(state.props, Object.assign(Object.assign({}, pendingAttachStates[i].args), { previousNode })); | ||
} | ||
}); | ||
} | ||
/* | ||
Compare old props and new props. | ||
We don't rerender if props remain the same. | ||
*/ | ||
function havePropsChanged(newProps, oldProps) { | ||
const oldKeys = Object.keys(oldProps); | ||
const newKeys = Object.keys(newProps); | ||
return (oldKeys.length !== newKeys.length || | ||
oldKeys.some((key) => oldProps[key] !== newProps[key])); | ||
} | ||
/* | ||
Mount will render the DOM as a child of the specified container element. | ||
*/ | ||
function mount(forgoNode, container) { | ||
let parentElement = isString(container) | ||
let parentElement = (isString(container) | ||
? env.document.querySelector(container) | ||
: container; | ||
: container); | ||
if (parentElement.nodeType !== ELEMENT_NODE_TYPE) { | ||
throw new Error("The container argument to mount() should be an HTML element."); | ||
} | ||
if (parentElement) { | ||
const { node } = internalRender(forgoNode, undefined, [], true); | ||
parentElement.appendChild(node); | ||
internalRender(forgoNode, { | ||
type: "search", | ||
currentNodeIndex: 0, | ||
length: 0, | ||
parentElement, | ||
}, []); | ||
} | ||
else { | ||
throw new Error(`Mount was called on a non-element (${parentElement}).`); | ||
throw new Error(`Mount was called on a non-element (${typeof container === "string" ? container : container === null || container === void 0 ? void 0 : container.tagName}).`); | ||
} | ||
@@ -529,3 +597,6 @@ } | ||
function render(forgoNode) { | ||
return internalRender(forgoNode, undefined, [], true); | ||
const renderResult = internalRender(forgoNode, { | ||
type: "detached", | ||
}, []); | ||
return { node: renderResult.nodes[0], nodes: renderResult.nodes }; | ||
} | ||
@@ -544,17 +615,39 @@ exports.render = render; | ||
if (element && element.node) { | ||
const state = getForgoState(element.node); | ||
if (state) { | ||
const componentState = state.components[element.componentIndex]; | ||
const effectiveProps = typeof props !== "undefined" ? props : componentState.props; | ||
if (!componentState.component.shouldUpdate || | ||
componentState.component.shouldUpdate(effectiveProps, componentState.props)) { | ||
const forgoNode = componentState.component.render(effectiveProps, componentState.args); | ||
const statesToAttach = state.components | ||
.slice(0, element.componentIndex) | ||
.concat(Object.assign(Object.assign({}, componentState), { props: effectiveProps })); | ||
internalRender(forgoNode, element.node, statesToAttach, fullRerender); | ||
const parentElement = element.node.parentElement; | ||
if (parentElement !== null) { | ||
const state = getForgoState(element.node); | ||
if (state) { | ||
const componentState = state.components[element.componentIndex]; | ||
const effectiveProps = typeof props !== "undefined" ? props : componentState.props; | ||
if (!componentState.component.shouldUpdate || | ||
componentState.component.shouldUpdate(effectiveProps, componentState.props)) { | ||
const newComponentState = Object.assign(Object.assign({}, componentState), { props: effectiveProps }); | ||
const statesToAttach = state.components | ||
.slice(0, element.componentIndex) | ||
.concat(newComponentState); | ||
const forgoNode = componentState.component.render(effectiveProps, componentState.args); | ||
const nodeIndex = Array.from(parentElement.childNodes).findIndex((x) => x === element.node); | ||
const insertionOptions = { | ||
type: "search", | ||
currentNodeIndex: nodeIndex, | ||
length: componentState.numNodes, | ||
parentElement, | ||
}; | ||
return renderComponentAndRemoveStaleNodes(forgoNode, insertionOptions, statesToAttach, newComponentState); | ||
} | ||
// shouldUpdate() returned false | ||
else { | ||
const allNodes = Array.from(parentElement.childNodes); | ||
const indexOfNode = allNodes.findIndex((x) => x === element.node); | ||
return { | ||
nodes: allNodes.slice(indexOfNode, indexOfNode + componentState.numNodes), | ||
}; | ||
} | ||
} | ||
else { | ||
throw new Error(`Missing forgo state on node.`); | ||
} | ||
} | ||
else { | ||
throw new Error(`Missing forgo state on node.`); | ||
throw new Error(`Rerender was called on a node without a parent element.`); | ||
} | ||
@@ -567,5 +660,12 @@ } | ||
exports.rerender = rerender; | ||
function flatten(array, ret = []) { | ||
for (const entry of array) { | ||
if (Array.isArray(entry)) { | ||
function flatten(itemOrItems, ret = []) { | ||
const items = Array.isArray(itemOrItems) | ||
? itemOrItems | ||
: isForgoFragment(itemOrItems) | ||
? Array.isArray(itemOrItems.props.children) | ||
? itemOrItems.props.children | ||
: [itemOrItems.props.children] | ||
: [itemOrItems]; | ||
for (const entry of items) { | ||
if (Array.isArray(entry) || isForgoFragment(entry)) { | ||
flatten(entry, ret); | ||
@@ -597,2 +697,8 @@ } | ||
} | ||
function isForgoCustomComponent(node) { | ||
return isForgoElement(node) && typeof node.type !== "string"; | ||
} | ||
function isForgoFragment(node) { | ||
return isForgoElement(node) && node.type === exports.Fragment; | ||
} | ||
/* | ||
@@ -609,3 +715,8 @@ Get the state (NodeAttachedState) saved into an element. | ||
function getExistingForgoState(node) { | ||
return node.__forgo; | ||
if (node.__forgo) { | ||
return node.__forgo; | ||
} | ||
else { | ||
throw new Error("Missing state in node."); | ||
} | ||
} | ||
@@ -612,0 +723,0 @@ /* |
@@ -5,2 +5,94 @@ export namespace JSX { | ||
} | ||
} | ||
} | ||
export type ForgoRef<T> = { | ||
value?: T; | ||
}; | ||
export type ForgoElementProps = { | ||
ref?: ForgoRef<HTMLElement>; | ||
children?: ForgoNode[]; | ||
}; | ||
export type ForgoComponentCtor<TProps extends ForgoElementProps> = ( | ||
props: TProps | ||
) => ForgoComponent<TProps>; | ||
export type ForgoElementArg = { | ||
node?: ChildNode; | ||
componentIndex: number; | ||
}; | ||
export type ForgoRenderArgs = { | ||
element: ForgoElementArg; | ||
}; | ||
export type ForgoErrorArgs = ForgoRenderArgs & { | ||
error: any; | ||
}; | ||
export type ForgoAfterRenderArgs = ForgoRenderArgs & { | ||
previousNode?: ChildNode; | ||
}; | ||
export type ForgoComponent<TProps extends ForgoElementProps> = { | ||
render: (props: TProps, args: ForgoRenderArgs) => ForgoNode | ForgoNode[]; | ||
afterRender?: (props: TProps, args: ForgoAfterRenderArgs) => void; | ||
error?: (props: TProps, args: ForgoErrorArgs) => ForgoNode; | ||
mount?: (props: TProps, args: ForgoRenderArgs) => void; | ||
unmount?: (props: TProps, args: ForgoRenderArgs) => void; | ||
shouldUpdate?: (newProps: TProps, oldProps: TProps) => boolean; | ||
}; | ||
export type ForgoElementBase<TProps extends ForgoElementProps> = { | ||
key?: any; | ||
props: TProps; | ||
__is_forgo_element__: true; | ||
}; | ||
export type ForgoDOMElement<TProps> = ForgoElementBase<TProps> & { | ||
type: string; | ||
}; | ||
export type ForgoCustomComponentElement<TProps> = ForgoElementBase<TProps> & { | ||
type: ForgoComponentCtor<TProps>; | ||
}; | ||
export type ForgoElement<TProps extends ForgoElementProps> = | ||
| ForgoDOMElement<TProps> | ||
| ForgoCustomComponentElement<TProps>; | ||
export type ForgoPrimitiveNode = | ||
| string | ||
| number | ||
| boolean | ||
| object | ||
| null | ||
| BigInt | ||
| undefined; | ||
export type ForgoNode = ForgoPrimitiveNode | ForgoElement<any>; | ||
export function jsxs<TProps extends ForgoElementProps>( | ||
type: string | ForgoComponentCtor<TProps>, | ||
props: TProps, | ||
key: any | ||
): { | ||
type: string | ForgoComponentCtor<TProps>; | ||
props: TProps; | ||
key: any; | ||
__is_forgo_element__: true; | ||
}; | ||
export function jsx<TProps extends ForgoElementProps>( | ||
type: string | ForgoComponentCtor<TProps>, | ||
props: TProps, | ||
key: any | ||
): { | ||
type: string | ForgoComponentCtor<TProps>; | ||
props: TProps; | ||
key: any; | ||
__is_forgo_element__: true; | ||
}; | ||
export function Fragment(): void; |
@@ -0,1 +1,3 @@ | ||
export { Fragment } from "../"; | ||
export function jsxs(type, props, key) { | ||
@@ -8,1 +10,2 @@ return { type, props, key, __is_forgo_element__: true }; | ||
} | ||
{ | ||
"name": "forgo", | ||
"version": "0.0.35", | ||
"version": "0.0.36", | ||
"main": "./dist", | ||
@@ -5,0 +5,0 @@ "author": "Jeswin Kumar<jeswinpk@agilehead.com>", |
@@ -23,7 +23,34 @@ # forgo | ||
An easy way to get a project started is by cloning one of the following templates. These templates use parcel as the bundler/build tool. | ||
### Starting a Forgo project | ||
There are a couple ready-made templates on GitHub to help you with the initial project scaffolding. These templates use webpack as the bundler/build tool. | ||
- [Starter-kit using JavaScript](https://github.com/forgojs/forgo-template-javascript) | ||
- [Starter-kit using TypeScript](https://github.com/forgojs/forgo-template-typescript) | ||
This process is easier with degit: | ||
For JavaScript: | ||
```sh | ||
npx degit forgojs/forgo-template-javascript#main my-project | ||
``` | ||
For TypeScript: | ||
```sh | ||
npx degit forgojs/forgo-template-typescript#main my-project | ||
``` | ||
And then to run it: | ||
```sh | ||
# switch to the project directory | ||
cd my-project | ||
# Install dependencies | ||
npm i | ||
# run! | ||
npm start | ||
``` | ||
## A Forgo Component | ||
@@ -242,3 +269,3 @@ | ||
## Component Unmount | ||
## The Unmount Event | ||
@@ -260,5 +287,5 @@ When a component is unmounted, Forgo will invoke the unmount() function if defined for a component. It receives the current props and args as arguments, just as in the render() function. This can be used for any tear down you might want to do. | ||
## Component mount | ||
## The Mount Event | ||
You'd rarely have to use this. mount() gets called with the same arguments as render () but after getting mounted on a real DOM node. At this point you can expect args.element.node to be populated, where args is the second parameter to mount() and render(). | ||
If you're an application developer, you'd rarely have to use this. It might however be useful if you're developing libraries or frameworks which use Forgo. mount() gets called with the same arguments as render(), but after getting mounted on a real DOM node. It gets called only once. | ||
@@ -278,2 +305,21 @@ ```jsx | ||
## The AfterRender Event | ||
Again, if you're an application developer you'd rarely need to use this. The afterRender() event runs every time after the render() runs, but after the rendered elements have been attached to actual DOM nodes. The 'previousNode' property of args will give you the node to which the component was previously attached, if it has changed due to the render(). | ||
```jsx | ||
function Greeter(initialProps) { | ||
return { | ||
render(props, args) { | ||
return <div id="hello">Hello {props.firstName}</div>; | ||
}, | ||
afterRender(props, args) { | ||
console.log( | ||
`This component is mounted on ${args.element.node.id}, and previously to ${args.previousNode.id}` | ||
); | ||
}, | ||
}; | ||
} | ||
``` | ||
## Bailing out of a render | ||
@@ -352,12 +398,7 @@ | ||
But there are a couple of handy options to rerender, 'newProps' and 'forceRerender'. | ||
But you could pass newProps as well while rerendering. If you'd like previous props to be used, pass undefined here. | ||
newProps let you pass a new set of props while rerendering. If you'd like previous props to be used, pass undefined here. | ||
forceRerender defaults to true, but when set to false skips child component rendering if props haven't changed. | ||
```js | ||
const newProps = { name: "Kai" }; | ||
const forceRerender = false; | ||
rerender(args.element, newProps, forceRerender); | ||
rerender(args.element, newProps); | ||
``` | ||
@@ -364,0 +405,0 @@ |
1090
src/index.ts
@@ -16,3 +16,3 @@ declare global { | ||
ref?: ForgoRef<HTMLElement>; | ||
children?: ForgoNode[]; | ||
children?: ForgoNode | ForgoNode[]; | ||
}; | ||
@@ -22,3 +22,3 @@ | ||
This is the constructor of a ForgoComponent, called a 'Component Constructor' | ||
The terminology is a little different from React here. | ||
@@ -41,7 +41,10 @@ For example, in <MyComponent />, the MyComponent is the Component Constructor. | ||
export type ForgoErrorArgs = { | ||
element: ForgoElementArg; | ||
export type ForgoErrorArgs = ForgoRenderArgs & { | ||
error: any; | ||
}; | ||
export type ForgoAfterRenderArgs = ForgoRenderArgs & { | ||
previousNode?: ChildNode; | ||
}; | ||
/* | ||
@@ -56,3 +59,4 @@ ForgoComponent contains three functions. | ||
export type ForgoComponent<TProps extends ForgoElementProps> = { | ||
render: (props: TProps, args: ForgoRenderArgs) => ForgoNode; | ||
render: (props: TProps, args: ForgoRenderArgs) => ForgoNode | ForgoNode[]; | ||
afterRender?: (props: TProps, args: ForgoAfterRenderArgs) => void; | ||
error?: (props: TProps, args: ForgoErrorArgs) => ForgoNode; | ||
@@ -70,3 +74,3 @@ mount?: (props: TProps, args: ForgoRenderArgs) => void; | ||
- or a Custom Component. | ||
If the ForgoNode is a string, number etc, it's a primitive type. | ||
@@ -95,7 +99,12 @@ eg: "hello" | ||
export type ForgoFragment = ForgoElementBase<any> & { | ||
type: typeof Fragment; | ||
}; | ||
export type ForgoElement<TProps extends ForgoElementProps> = | ||
| ForgoDOMElement<TProps> | ||
| ForgoCustomComponentElement<TProps>; | ||
| ForgoCustomComponentElement<TProps> | ||
| ForgoFragment; | ||
export type ForgoNode = | ||
export type ForgoPrimitiveNode = | ||
| string | ||
@@ -107,5 +116,6 @@ | number | ||
| BigInt | ||
| undefined | ||
| ForgoElement<any>; | ||
| undefined; | ||
export type ForgoNode = ForgoPrimitiveNode | ForgoElement<any>; | ||
/* | ||
@@ -116,3 +126,3 @@ Forgo stores Component state on the element on which it is mounted. | ||
In this case, the components Custom1, Custom2 and Custom3 are stored on the div. | ||
You can also see that it gets passed around as pendingStates in the render methods. | ||
@@ -130,2 +140,3 @@ That's because when Custom1 renders Custom2, there isn't a real DOM node available to attach the state to. So the states are passed around until the last component renders a real DOM node. | ||
args: ForgoRenderArgs; | ||
numNodes: number; | ||
}; | ||
@@ -174,5 +185,38 @@ | ||
/* | ||
NodeReplacementOptions decide how nodes get attached by the callee function. | ||
type = "detached" does not attach the node to the parent. | ||
type = "search" requires the callee function to search for a compatible replacement. | ||
*/ | ||
export type DetachedNodeInsertionOptions = { | ||
type: "detached"; | ||
}; | ||
export type SearchableNodeInsertionOptions = { | ||
type: "search"; | ||
parentElement: HTMLElement; | ||
currentNodeIndex: number; | ||
length: number; | ||
}; | ||
export type NodeInsertionOptions = | ||
| DetachedNodeInsertionOptions | ||
| SearchableNodeInsertionOptions; | ||
/* | ||
Result of the render functions | ||
*/ | ||
export type RenderResult = { | ||
nodes: ChildNode[]; | ||
}; | ||
/* | ||
Fragment constructor. | ||
We simply use it as a marker in jsx-runtime. | ||
*/ | ||
export const Fragment: unique symbol = Symbol("FORGO_FRAGMENT"); | ||
/* | ||
This is the main render function. | ||
forgoNode is the node to render. | ||
node is an existing node to which the element needs to be rendered (if rerendering) | ||
@@ -184,17 +228,14 @@ May not always be passed in, like for first render or when no compatible node exists. | ||
function internalRender( | ||
forgoNode: ForgoNode, | ||
targetNode: ChildNode | undefined, | ||
pendingAttachStates: NodeAttachedComponentState<any>[], | ||
fullRerender: boolean, | ||
boundary?: ForgoComponent<any> | ||
): { node: ChildNode; boundary?: ForgoComponent<any> } { | ||
// Just a string | ||
if (!isForgoElement(forgoNode)) { | ||
return renderString( | ||
stringOfPrimitiveNode(forgoNode), | ||
targetNode, | ||
pendingAttachStates, | ||
fullRerender | ||
); | ||
forgoNode: ForgoNode | ForgoNode[], | ||
nodeInsertionOptions: NodeInsertionOptions, | ||
pendingAttachStates: NodeAttachedComponentState<any>[] | ||
): RenderResult { | ||
// Array of Nodes | ||
if (Array.isArray(forgoNode)) { | ||
return renderArray(forgoNode, nodeInsertionOptions, pendingAttachStates); | ||
} | ||
// Primitive Nodes | ||
else if (!isForgoElement(forgoNode)) { | ||
return renderText(forgoNode, nodeInsertionOptions, pendingAttachStates); | ||
} | ||
// HTML Element | ||
@@ -204,17 +245,14 @@ else if (isForgoDOMElement(forgoNode)) { | ||
forgoNode, | ||
targetNode, | ||
pendingAttachStates, | ||
fullRerender, | ||
boundary | ||
nodeInsertionOptions, | ||
pendingAttachStates | ||
); | ||
} else if (isForgoFragment(forgoNode)) { | ||
return renderFragment(forgoNode, nodeInsertionOptions, pendingAttachStates); | ||
} | ||
// Custom Component. | ||
// We don't renderChildren since that is the CustomComponent's prerogative. | ||
else { | ||
return renderCustomComponent( | ||
forgoNode, | ||
targetNode as Required<ChildNode>, | ||
pendingAttachStates, | ||
fullRerender, | ||
boundary | ||
nodeInsertionOptions, | ||
pendingAttachStates | ||
); | ||
@@ -225,4 +263,4 @@ } | ||
/* | ||
Render a string. | ||
Render a string. | ||
Such as in the render function below: | ||
@@ -237,36 +275,50 @@ function MyComponent() { | ||
*/ | ||
function renderString( | ||
text: string, | ||
targetNode: ChildNode | undefined, | ||
pendingAttachStates: NodeAttachedComponentState<any>[], | ||
fullRerender: boolean | ||
): { node: ChildNode } { | ||
function renderText( | ||
forgoNode: ForgoPrimitiveNode, | ||
nodeInsertionOptions: NodeInsertionOptions, | ||
pendingAttachStates: NodeAttachedComponentState<any>[] | ||
): RenderResult { | ||
// Text nodes will always be recreated | ||
const textNode = env.document.createTextNode(text); | ||
const textNode: ChildNode = env.document.createTextNode( | ||
stringOfPrimitiveNode(forgoNode) | ||
); | ||
if (targetNode) { | ||
// We have to get oldStates before attachProps; | ||
// coz attachProps will overwrite with new states. | ||
const oldComponentStates = getForgoState(targetNode)?.components; | ||
attachProps(text, textNode, pendingAttachStates); | ||
if (oldComponentStates) { | ||
const indexOfFirstIncompatibleState = findIndexOfFirstIncompatibleState( | ||
pendingAttachStates, | ||
oldComponentStates | ||
); | ||
unmountComponents(oldComponentStates, indexOfFirstIncompatibleState); | ||
mountComponents(pendingAttachStates, indexOfFirstIncompatibleState); | ||
} else { | ||
mountComponents(pendingAttachStates, 0); | ||
// We need to create a detached node | ||
if (nodeInsertionOptions.type === "detached") { | ||
syncStateAndProps(forgoNode, textNode, textNode, pendingAttachStates); | ||
return { nodes: [textNode] }; | ||
} | ||
// We have to find a node to replace. | ||
else { | ||
// If we're searching in a list, we replace if the current node is a text node. | ||
const childNodes = nodeInsertionOptions.parentElement.childNodes; | ||
if (nodeInsertionOptions.length) { | ||
let targetNode = childNodes[nodeInsertionOptions.currentNodeIndex]; | ||
if (targetNode.nodeType === TEXT_NODE_TYPE) { | ||
syncStateAndProps(forgoNode, textNode, targetNode, pendingAttachStates); | ||
targetNode.replaceWith(textNode); | ||
return { nodes: [textNode] }; | ||
} else { | ||
syncStateAndProps(forgoNode, textNode, textNode, pendingAttachStates); | ||
const nextNode = childNodes[nodeInsertionOptions.currentNodeIndex]; | ||
nodeInsertionOptions.parentElement.insertBefore(textNode, nextNode); | ||
return { nodes: [textNode] }; | ||
} | ||
} | ||
targetNode.replaceWith(textNode); | ||
} else { | ||
attachProps(text, textNode, pendingAttachStates); | ||
mountComponents(pendingAttachStates, 0); | ||
// There are no target nodes available | ||
else { | ||
const childNodes = nodeInsertionOptions.parentElement.childNodes; | ||
syncStateAndProps(forgoNode, textNode, textNode, pendingAttachStates); | ||
if ( | ||
childNodes.length === 0 || | ||
nodeInsertionOptions.currentNodeIndex === 0 | ||
) { | ||
nodeInsertionOptions.parentElement.prepend(textNode); | ||
} else { | ||
const nextNode = childNodes[nodeInsertionOptions.currentNodeIndex]; | ||
nodeInsertionOptions.parentElement.insertBefore(textNode, nextNode); | ||
} | ||
return { nodes: [textNode] }; | ||
} | ||
} | ||
return { node: textNode }; | ||
} | ||
@@ -276,3 +328,3 @@ | ||
Render a DOM element. | ||
Such as in the render function below: | ||
@@ -289,50 +341,102 @@ function MyComponent() { | ||
forgoElement: ForgoDOMElement<TProps>, | ||
targetNode: ChildNode | undefined, | ||
pendingAttachStates: NodeAttachedComponentState<any>[], | ||
fullRerender: boolean, | ||
boundary?: ForgoComponent<any> | ||
): { node: ChildNode } { | ||
if (targetNode) { | ||
let nodeToBindTo: ChildNode; | ||
nodeInsertionOptions: NodeInsertionOptions, | ||
pendingAttachStates: NodeAttachedComponentState<any>[] | ||
): RenderResult { | ||
// We need to create a detached node | ||
if (nodeInsertionOptions.type === "detached") { | ||
let newElement: HTMLElement = env.document.createElement(forgoElement.type); | ||
syncStateAndProps( | ||
forgoElement, | ||
newElement, | ||
newElement, | ||
pendingAttachStates | ||
); | ||
renderDOMElementChildNodes(newElement); | ||
return { nodes: [newElement] }; | ||
} | ||
// We have to find a node to replace. | ||
else { | ||
const childNodes = nodeInsertionOptions.parentElement.childNodes; | ||
if (nodeInsertionOptions.length) { | ||
const searchResult = findReplacementCandidateForDOMElement( | ||
forgoElement, | ||
childNodes, | ||
nodeInsertionOptions.currentNodeIndex, | ||
nodeInsertionOptions.length | ||
); | ||
// if the nodes are not of the same of the same type, we need to replace. | ||
if ( | ||
targetNode.nodeType === TEXT_NODE_TYPE || | ||
((targetNode as HTMLElement).tagName && | ||
(targetNode as HTMLElement).tagName.toLowerCase() !== forgoElement.type) | ||
) { | ||
const newElement = env.document.createElement(forgoElement.type); | ||
targetNode.replaceWith(newElement); | ||
nodeToBindTo = newElement; | ||
if (searchResult.found) { | ||
// Get rid of unwanted nodes. | ||
unloadNodes( | ||
Array.from(childNodes).slice( | ||
nodeInsertionOptions.currentNodeIndex, | ||
searchResult.index | ||
) | ||
); | ||
const targetNode = childNodes[searchResult.index] as HTMLElement; | ||
syncStateAndProps( | ||
forgoElement, | ||
targetNode, | ||
targetNode, | ||
pendingAttachStates | ||
); | ||
renderDOMElementChildNodes(targetNode); | ||
return { nodes: [targetNode] }; | ||
} else { | ||
const newElement = addNewDOMElement( | ||
nodeInsertionOptions.parentElement, | ||
childNodes[nodeInsertionOptions.currentNodeIndex] | ||
); | ||
return { nodes: [newElement] }; | ||
} | ||
} else { | ||
nodeToBindTo = targetNode; | ||
const newElement = addNewDOMElement( | ||
nodeInsertionOptions.parentElement, | ||
childNodes[nodeInsertionOptions.currentNodeIndex] | ||
); | ||
return { nodes: [newElement] }; | ||
} | ||
} | ||
// We have to get oldStates before attachProps; | ||
// coz attachProps will overwrite with new states. | ||
const oldComponentStates = getForgoState(targetNode)?.components; | ||
function renderDOMElementChildNodes(parentElement: HTMLElement) { | ||
const forgoChildrenObj = forgoElement.props.children; | ||
attachProps(forgoElement, nodeToBindTo, pendingAttachStates); | ||
// Children will not be an array if single item | ||
const forgoChildren = flatten( | ||
(Array.isArray(forgoChildrenObj) | ||
? forgoChildrenObj | ||
: [forgoChildrenObj] | ||
).filter((x) => typeof x !== "undefined" && x !== null) | ||
); | ||
if (oldComponentStates) { | ||
const indexOfFirstIncompatibleState = findIndexOfFirstIncompatibleState( | ||
pendingAttachStates, | ||
oldComponentStates | ||
let currentChildNodeIndex = 0; | ||
for (const forgoChild of forgoChildren) { | ||
const { nodes } = internalRender( | ||
forgoChild, | ||
{ | ||
type: "search", | ||
parentElement, | ||
currentNodeIndex: currentChildNodeIndex, | ||
length: parentElement.childNodes.length - currentChildNodeIndex, | ||
}, | ||
[] | ||
); | ||
unmountComponents(oldComponentStates, indexOfFirstIncompatibleState); | ||
mountComponents(pendingAttachStates, indexOfFirstIncompatibleState); | ||
} else { | ||
mountComponents(pendingAttachStates, 0); | ||
currentChildNodeIndex += nodes.length; | ||
} | ||
renderChildNodes( | ||
forgoElement, | ||
nodeToBindTo as HTMLElement, | ||
fullRerender, | ||
boundary | ||
// Get rid the the remaining nodes | ||
unloadNodes( | ||
Array.from(parentElement.childNodes).slice(currentChildNodeIndex) | ||
); | ||
return { node: nodeToBindTo }; | ||
} | ||
// There was no node passed in; have to create a new element. | ||
else { | ||
function addNewDOMElement( | ||
parentElement: HTMLElement, | ||
oldNode: ChildNode | ||
): HTMLElement { | ||
const newElement = env.document.createElement(forgoElement.type); | ||
@@ -342,37 +446,14 @@ if (forgoElement.props.ref) { | ||
} | ||
attachProps(forgoElement, newElement, pendingAttachStates); | ||
mountComponents(pendingAttachStates, 0); | ||
renderChildNodes(forgoElement, newElement, fullRerender, boundary); | ||
return { node: newElement }; | ||
parentElement.insertBefore(newElement, oldNode); | ||
syncStateAndProps( | ||
forgoElement, | ||
newElement, | ||
newElement, | ||
pendingAttachStates | ||
); | ||
renderDOMElementChildNodes(newElement); | ||
return newElement; | ||
} | ||
} | ||
function boundaryFallback<T>( | ||
targetNode: ChildNode | undefined, | ||
props: any, | ||
args: ForgoRenderArgs, | ||
statesToAttach: NodeAttachedComponentState<any>[], | ||
fullRerender: boolean, | ||
boundary: ForgoComponent<any> | undefined, | ||
exec: () => T | ||
) { | ||
try { | ||
return exec(); | ||
} catch (error) { | ||
if (boundary && boundary.error) { | ||
const errorArgs = { ...args, error }; | ||
const newForgoElement = boundary.error(props, errorArgs); | ||
return internalRender( | ||
newForgoElement, | ||
targetNode, | ||
statesToAttach, | ||
fullRerender, | ||
boundary | ||
); | ||
} else { | ||
throw error; | ||
} | ||
} | ||
} | ||
/* | ||
@@ -384,109 +465,105 @@ Render a Custom Component | ||
forgoElement: ForgoCustomComponentElement<TProps>, | ||
targetNode: Required<ChildNode> | undefined, | ||
pendingAttachStates: NodeAttachedComponentState<any>[], | ||
fullRerender: boolean, | ||
boundary?: ForgoComponent<any> | ||
): { node: ChildNode; boundary?: ForgoComponent<any> } { | ||
if (targetNode) { | ||
const state = getExistingForgoState(targetNode); | ||
nodeInsertionOptions: NodeInsertionOptions, | ||
pendingAttachStates: NodeAttachedComponentState<any>[] | ||
// boundary: ForgoComponent<any> | undefined | ||
): RenderResult { | ||
const componentIndex = pendingAttachStates.length; | ||
const componentIndex = pendingAttachStates.length; | ||
const componentState = state.components[componentIndex]; | ||
const haveCompatibleState = | ||
componentState && componentState.ctor === forgoElement.type; | ||
// We need to create a detached node | ||
if (nodeInsertionOptions.type === "detached") { | ||
return addNewComponent(); | ||
} | ||
// We have to find a node to replace. | ||
else { | ||
if (nodeInsertionOptions.length) { | ||
const childNodes = nodeInsertionOptions.parentElement.childNodes; | ||
const searchResult = findReplacementCandidateForCustomComponent( | ||
forgoElement, | ||
childNodes, | ||
nodeInsertionOptions.currentNodeIndex, | ||
nodeInsertionOptions.length | ||
); | ||
// We have compatible state, and this is a rerender | ||
if (haveCompatibleState) { | ||
if ( | ||
fullRerender || | ||
havePropsChanged(forgoElement.props, componentState.props) | ||
) { | ||
if ( | ||
!componentState.component.shouldUpdate || | ||
componentState.component.shouldUpdate( | ||
forgoElement.props, | ||
componentState.props | ||
if (searchResult.found) { | ||
// Get rid of unwanted nodes. | ||
unloadNodes( | ||
Array.from(childNodes).slice( | ||
nodeInsertionOptions.currentNodeIndex, | ||
searchResult.index | ||
) | ||
) { | ||
// Since we have compatible state already stored, | ||
// we'll push the savedComponentState into pending states for later attachment. | ||
const statesToAttach = pendingAttachStates.concat({ | ||
...componentState, | ||
props: forgoElement.props, | ||
}); | ||
); | ||
// Get a new element by calling render on existing component. | ||
const newForgoElement = componentState.component.render( | ||
forgoElement.props, | ||
componentState.args | ||
); | ||
const targetNode = childNodes[nodeInsertionOptions.currentNodeIndex]; | ||
const state = getExistingForgoState(targetNode); | ||
const componentState = state.components[componentIndex]; | ||
return boundaryFallback( | ||
targetNode, | ||
forgoElement.props, | ||
componentState.args, | ||
statesToAttach, | ||
fullRerender, | ||
boundary, | ||
() => { | ||
// Pass it on for rendering... | ||
return internalRender( | ||
newForgoElement, | ||
targetNode, | ||
statesToAttach, | ||
fullRerender, | ||
boundary | ||
); | ||
} | ||
); | ||
const haveCompatibleState = | ||
componentState && componentState.ctor === forgoElement.type; | ||
if (haveCompatibleState) { | ||
return renderExistingComponent(nodeInsertionOptions, componentState); | ||
} else { | ||
return addNewComponent(); | ||
} | ||
// shouldUpdate() returned false | ||
else { | ||
return { node: targetNode, boundary }; | ||
} | ||
} | ||
// not a fullRender and havePropsChanged() returned false | ||
// No matching node found | ||
else { | ||
return { node: targetNode, boundary }; | ||
return addNewComponent(); | ||
} | ||
} | ||
// We don't have compatible state, have to create a new component. | ||
// No nodes in target node list | ||
else { | ||
const args: ForgoRenderArgs = { element: { componentIndex } }; | ||
return addNewComponent(); | ||
} | ||
} | ||
const ctor = forgoElement.type; | ||
const component = ctor(forgoElement.props); | ||
assertIsComponent(ctor, component); | ||
boundary = component.error ? component : boundary; | ||
// Create new component state | ||
// ... and push it to pendingAttachStates | ||
function renderExistingComponent( | ||
nodeInsertionOptions: SearchableNodeInsertionOptions, | ||
componentState: NodeAttachedComponentState<TProps> | ||
): RenderResult { | ||
if ( | ||
!componentState.component.shouldUpdate || | ||
componentState.component.shouldUpdate( | ||
forgoElement.props, | ||
componentState.props | ||
) | ||
) { | ||
// Since we have compatible state already stored, | ||
// we'll push the savedComponentState into pending states for later attachment. | ||
const newComponentState = { | ||
key: forgoElement.key, | ||
ctor, | ||
component, | ||
...componentState, | ||
props: forgoElement.props, | ||
args, | ||
}; | ||
const statesToAttach = pendingAttachStates.concat(newComponentState); | ||
return boundaryFallback( | ||
targetNode, | ||
// Get a new element by calling render on existing component. | ||
const newForgoNode = newComponentState.component.render( | ||
forgoElement.props, | ||
args, | ||
newComponentState.args | ||
); | ||
const boundary = newComponentState.component.error | ||
? newComponentState.component | ||
: undefined; | ||
return withErrorBoundary( | ||
forgoElement.props, | ||
newComponentState.args, | ||
statesToAttach, | ||
fullRerender, | ||
boundary, | ||
() => { | ||
// Create an element by rendering the component | ||
const newForgoElement = component.render(forgoElement.props, args); | ||
// Create new node insertion options. | ||
const insertionOptions: NodeInsertionOptions = { | ||
type: "search", | ||
currentNodeIndex: nodeInsertionOptions.currentNodeIndex, | ||
length: newComponentState.numNodes, | ||
parentElement: nodeInsertionOptions.parentElement, | ||
}; | ||
// Pass it on for rendering... | ||
return internalRender( | ||
newForgoElement, | ||
targetNode, | ||
return renderComponentAndRemoveStaleNodes( | ||
newForgoNode, | ||
insertionOptions, | ||
statesToAttach, | ||
fullRerender, | ||
boundary | ||
newComponentState | ||
); | ||
@@ -496,8 +573,22 @@ } | ||
} | ||
// shouldUpdate() returned false | ||
else { | ||
const allNodes = Array.from( | ||
nodeInsertionOptions.parentElement.childNodes | ||
); | ||
const indexOfNode = allNodes.findIndex( | ||
(x) => x === componentState.args.element.node | ||
); | ||
return { | ||
nodes: allNodes.slice( | ||
indexOfNode, | ||
indexOfNode + componentState.numNodes | ||
), | ||
}; | ||
} | ||
} | ||
// First time render | ||
else { | ||
const args: ForgoRenderArgs = { | ||
element: { componentIndex: pendingAttachStates.length }, | ||
}; | ||
function addNewComponent(): RenderResult { | ||
const args: ForgoRenderArgs = { element: { componentIndex } }; | ||
const ctor = forgoElement.type; | ||
@@ -507,7 +598,7 @@ const component = ctor(forgoElement.props); | ||
boundary = component.error ? component : boundary; | ||
const boundary = component.error ? component : undefined; | ||
// We'll have to create a new component state | ||
// Create new component state | ||
// ... and push it to pendingAttachStates | ||
const componentState = { | ||
const newComponentState = { | ||
key: forgoElement.key, | ||
@@ -518,180 +609,206 @@ ctor, | ||
args, | ||
numNodes: 0, | ||
}; | ||
const statesToAttach = pendingAttachStates.concat(componentState); | ||
const statesToAttach = pendingAttachStates.concat(newComponentState); | ||
// We have no node to render to yet. So pass undefined for the node. | ||
return boundaryFallback( | ||
undefined, | ||
return withErrorBoundary( | ||
forgoElement.props, | ||
args, | ||
statesToAttach, | ||
fullRerender, | ||
boundary, | ||
() => { | ||
// Create an element by rendering the component | ||
const newForgoElement = component.render(forgoElement.props, args); | ||
// Create new node insertion options. | ||
const insertionOptions: NodeInsertionOptions = | ||
nodeInsertionOptions.type === "detached" | ||
? nodeInsertionOptions | ||
: { | ||
type: "search", | ||
currentNodeIndex: nodeInsertionOptions.currentNodeIndex, | ||
length: 0, | ||
parentElement: nodeInsertionOptions.parentElement, | ||
}; | ||
// Pass it on for rendering... | ||
return internalRender( | ||
const renderResult = internalRender( | ||
newForgoElement, | ||
undefined, | ||
statesToAttach, | ||
fullRerender, | ||
boundary | ||
insertionOptions, | ||
statesToAttach | ||
); | ||
// In case we rendered an array, set the node to the first node. | ||
// And set numNodes. | ||
newComponentState.numNodes = renderResult.nodes.length; | ||
if (renderResult.nodes.length > 1) { | ||
newComponentState.args.element.node = renderResult.nodes[0]; | ||
} | ||
return renderResult; | ||
} | ||
); | ||
} | ||
function withErrorBoundary( | ||
props: TProps, | ||
args: ForgoRenderArgs, | ||
statesToAttach: NodeAttachedComponentState<any>[], | ||
boundary: ForgoComponent<any> | undefined, | ||
exec: () => RenderResult | ||
): RenderResult { | ||
try { | ||
return exec(); | ||
} catch (error) { | ||
if (boundary && boundary.error) { | ||
const errorArgs = { ...args, error }; | ||
const newForgoElement = boundary.error(props, errorArgs); | ||
return internalRender( | ||
newForgoElement, | ||
nodeInsertionOptions, | ||
statesToAttach | ||
); | ||
} else { | ||
throw error; | ||
} | ||
} | ||
} | ||
} | ||
/* | ||
Loop through and render child nodes of a forgo DOM element. | ||
function renderComponentAndRemoveStaleNodes<TProps>( | ||
forgoNode: ForgoNode, | ||
insertionOptions: SearchableNodeInsertionOptions, | ||
statesToAttach: NodeAttachedComponentState<any>[], | ||
componentState: NodeAttachedComponentState<TProps> | ||
): RenderResult { | ||
const totalNodesBeforeRender = | ||
insertionOptions.parentElement.childNodes.length; | ||
In the following example, if the forgoElement represents the 'parent' node, render the child nodes. | ||
eg: | ||
<div id="parent"> | ||
<MyTopBar /> | ||
<p id="first-child">some content goes here...</p> | ||
<MyFooter /> | ||
</div> | ||
// Pass it on for rendering... | ||
const renderResult = internalRender( | ||
forgoNode, | ||
insertionOptions, | ||
statesToAttach | ||
); | ||
The parentElement is the actual DOM element which corresponds to forgoElement. | ||
const totalNodesAfterRender = | ||
insertionOptions.parentElement.childNodes.length; | ||
const numNodesRemoved = | ||
totalNodesBeforeRender + renderResult.nodes.length - totalNodesAfterRender; | ||
const newIndex = | ||
insertionOptions.currentNodeIndex + renderResult.nodes.length; | ||
const nodesToRemove = Array.from( | ||
insertionOptions.parentElement.childNodes | ||
).slice(newIndex, newIndex + componentState.numNodes - numNodesRemoved); | ||
unloadNodes(nodesToRemove); | ||
// In case we rendered an array, set the node to the first node. | ||
// And set numNodes. | ||
componentState.numNodes = renderResult.nodes.length; | ||
if (renderResult.nodes.length > 1) { | ||
componentState.args.element.node = renderResult.nodes[0]; | ||
} | ||
return renderResult; | ||
} | ||
/* | ||
Render an array of components | ||
Called when a CustomComponent returns an array (or fragment) in its render method. | ||
*/ | ||
function renderChildNodes<TProps extends ForgoElementProps>( | ||
forgoParentElement: ForgoDOMElement<TProps>, | ||
parentElement: HTMLElement, | ||
fullRerender: boolean, | ||
boundary?: ForgoComponent<any> | ||
) { | ||
const { children: forgoChildrenObj } = forgoParentElement.props; | ||
const childNodes = parentElement.childNodes; | ||
function renderArray( | ||
forgoNodes: ForgoNode[], | ||
nodeInsertionOptions: NodeInsertionOptions, | ||
pendingAttachStates: NodeAttachedComponentState<any>[] | ||
): RenderResult { | ||
const flattenedNodes = flatten(forgoNodes); | ||
if (nodeInsertionOptions.type === "detached") { | ||
throw new Error( | ||
"Arrays and fragments cannot be rendered at the top level." | ||
); | ||
} else { | ||
let allNodes: ChildNode[] = []; | ||
// Children will not be an array if single item | ||
const forgoChildren = flatten( | ||
(Array.isArray(forgoChildrenObj) | ||
? forgoChildrenObj | ||
: [forgoChildrenObj] | ||
).filter((x) => typeof x !== "undefined" && x !== null) | ||
); | ||
let currentNodeIndex = nodeInsertionOptions.currentNodeIndex; | ||
let numNodes = nodeInsertionOptions.length; | ||
let forgoChildIndex = 0; | ||
let numNodesCreated = 0; | ||
for (const forgoNode of flattenedNodes) { | ||
const totalNodesBeforeRender = | ||
nodeInsertionOptions.parentElement.childNodes.length; | ||
if (forgoChildren) { | ||
for ( | ||
forgoChildIndex = 0; | ||
forgoChildIndex < forgoChildren.length; | ||
forgoChildIndex++ | ||
) { | ||
const forgoChild = forgoChildren[forgoChildIndex]; | ||
const insertionOptions: SearchableNodeInsertionOptions = { | ||
...nodeInsertionOptions, | ||
currentNodeIndex, | ||
length: numNodes, | ||
}; | ||
// This is a primitive node, such as string | number etc. | ||
if (!isForgoElement(forgoChild)) { | ||
// If the first node is a text node, we could pass that along. | ||
// No need to replace here, callee does that. | ||
if ( | ||
childNodes[forgoChildIndex] && | ||
childNodes[forgoChildIndex].nodeType === TEXT_NODE_TYPE | ||
) { | ||
renderString( | ||
stringOfPrimitiveNode(forgoChild), | ||
childNodes[forgoChildIndex], | ||
[], | ||
fullRerender | ||
); | ||
} | ||
// But otherwise, don't pass a replacement node. Just insert instead. | ||
else { | ||
const { node } = renderString( | ||
stringOfPrimitiveNode(forgoChild), | ||
undefined, | ||
[], | ||
fullRerender | ||
); | ||
const { nodes } = internalRender( | ||
forgoNode, | ||
insertionOptions, | ||
pendingAttachStates | ||
); | ||
const totalNodesAfterRender = | ||
nodeInsertionOptions.parentElement.childNodes.length; | ||
if (childNodes.length > forgoChildIndex) { | ||
parentElement.insertBefore(node, childNodes[forgoChildIndex]); | ||
} else { | ||
parentElement.appendChild(node); | ||
} | ||
} | ||
// We have added one node. | ||
numNodesCreated++; | ||
} | ||
// This is a Forgo DOM Element | ||
else if (isForgoDOMElement(forgoChild)) { | ||
const findResult = findReplacementCandidateForDOMElement( | ||
forgoChild, | ||
childNodes, | ||
forgoChildIndex | ||
); | ||
const numNodesRemoved = | ||
totalNodesBeforeRender + nodes.length - totalNodesAfterRender; | ||
if (findResult.found) { | ||
const nodesToRemove = Array.from(childNodes).slice( | ||
forgoChildIndex, | ||
findResult.index | ||
); | ||
unloadNodes(nodesToRemove); | ||
renderDOMElement( | ||
forgoChild, | ||
childNodes[forgoChildIndex], | ||
[], | ||
fullRerender | ||
); | ||
} else { | ||
const { node } = internalRender( | ||
forgoChild, | ||
undefined, | ||
[], | ||
fullRerender | ||
); | ||
if (childNodes.length > forgoChildIndex) { | ||
parentElement.insertBefore(node, childNodes[forgoChildIndex]); | ||
} else { | ||
parentElement.appendChild(node); | ||
} | ||
} | ||
} | ||
// This is a Custom Component | ||
else { | ||
const findResult = findReplacementCandidateForCustomComponent( | ||
forgoChild, | ||
childNodes, | ||
forgoChildIndex | ||
); | ||
currentNodeIndex += nodes.length; | ||
numNodes -= numNodesRemoved; | ||
if (findResult.found) { | ||
const nodesToRemove = Array.from(childNodes).slice( | ||
forgoChildIndex, | ||
findResult.index | ||
); | ||
unloadNodes(nodesToRemove); | ||
internalRender( | ||
forgoChild, | ||
childNodes[forgoChildIndex], | ||
[], | ||
fullRerender | ||
); | ||
} else { | ||
const { node } = internalRender( | ||
forgoChild, | ||
undefined, | ||
[], | ||
fullRerender | ||
); | ||
if (childNodes.length > forgoChildIndex) { | ||
parentElement.insertBefore(node, childNodes[forgoChildIndex]); | ||
} else { | ||
parentElement.appendChild(node); | ||
} | ||
} | ||
} | ||
allNodes = allNodes.concat(nodes); | ||
} | ||
return { nodes: allNodes }; | ||
} | ||
// Now we gotta remove old nodes which aren't being used. | ||
// Everything after forgoChildIndex must go. | ||
const nodesToRemove = Array.from(childNodes).slice(forgoChildIndex); | ||
unloadNodes(nodesToRemove); | ||
} | ||
/* | ||
Render a Fragment | ||
*/ | ||
function renderFragment( | ||
fragment: ForgoFragment, | ||
nodeInsertionOptions: NodeInsertionOptions, | ||
pendingAttachStates: NodeAttachedComponentState<any>[] | ||
): RenderResult { | ||
return renderArray( | ||
flatten(fragment), | ||
nodeInsertionOptions, | ||
pendingAttachStates | ||
); | ||
} | ||
/* | ||
Sync component states and props between a newNode and an oldNode. | ||
*/ | ||
function syncStateAndProps( | ||
forgoNode: ForgoNode, | ||
newNode: ChildNode, | ||
targetNode: ChildNode, | ||
pendingAttachStates: NodeAttachedComponentState<any>[] | ||
) { | ||
// We have to get oldStates before attachProps; | ||
// coz attachProps will overwrite with new states. | ||
const oldComponentStates = getForgoState(targetNode)?.components; | ||
attachProps(forgoNode, newNode, pendingAttachStates); | ||
if (oldComponentStates) { | ||
const indexOfFirstIncompatibleState = findIndexOfFirstIncompatibleState( | ||
pendingAttachStates, | ||
oldComponentStates | ||
); | ||
unmountComponents(oldComponentStates, indexOfFirstIncompatibleState); | ||
mountComponents(pendingAttachStates, indexOfFirstIncompatibleState); | ||
} else { | ||
mountComponents(pendingAttachStates, 0); | ||
} | ||
} | ||
/* | ||
Unloads components from a node list | ||
@@ -704,3 +821,2 @@ This does: | ||
for (const node of nodes) { | ||
node.remove(); | ||
const state = getForgoState(node); | ||
@@ -710,2 +826,3 @@ if (state) { | ||
} | ||
node.remove(); | ||
} | ||
@@ -744,3 +861,8 @@ } | ||
function mountComponents( | ||
/* | ||
Unmount components above an index. | ||
This is going to be passed a stale state[] | ||
The from param is the index at which stale state[] differs from new state[] | ||
*/ | ||
function unmountComponents( | ||
states: NodeAttachedComponentState<any>[], | ||
@@ -751,4 +873,4 @@ from: number | ||
const state = states[j]; | ||
if (state.component.mount) { | ||
state.component.mount(state.props, state.args); | ||
if (state.component.unmount) { | ||
state.component.unmount(state.props, state.args); | ||
} | ||
@@ -758,3 +880,8 @@ } | ||
function unmountComponents( | ||
/* | ||
Mount components above an index. | ||
This is going to be passed the new state[]. | ||
The from param is the index at which stale state[] differs from new state[] | ||
*/ | ||
function mountComponents( | ||
states: NodeAttachedComponentState<any>[], | ||
@@ -765,4 +892,4 @@ from: number | ||
const state = states[j]; | ||
if (state.component.unmount) { | ||
state.component.unmount(state.props, state.args); | ||
if (state.component.mount) { | ||
state.component.mount(state.props, state.args); | ||
} | ||
@@ -786,6 +913,7 @@ } | ||
forgoElement: ForgoDOMElement<TProps>, | ||
nodes: NodeListOf<ChildNode>, | ||
searchNodesFrom: number | ||
nodes: NodeListOf<ChildNode> | ChildNode[], | ||
searchFrom: number, | ||
length: number | ||
): CandidateSearchResult { | ||
for (let i = searchNodesFrom; i < nodes.length; i++) { | ||
for (let i = searchFrom; i < searchFrom + length; i++) { | ||
const node = nodes[i] as ChildNode; | ||
@@ -817,6 +945,7 @@ if (forgoElement.key) { | ||
forgoElement: ForgoCustomComponentElement<TProps>, | ||
nodes: NodeListOf<ChildNode>, | ||
searchNodesFrom: number | ||
nodes: NodeListOf<ChildNode> | ChildNode[], | ||
searchFrom: number, | ||
length: number | ||
): CandidateSearchResult { | ||
for (let i = searchNodesFrom; i < nodes.length; i++) { | ||
for (let i = searchFrom; i < searchFrom + length; i++) { | ||
const node = nodes[i] as ChildNode; | ||
@@ -848,2 +977,5 @@ const stateOnNode = getForgoState(node); | ||
) { | ||
// Capture previous nodes if afterRender is defined; | ||
const previousNodes: (ChildNode | undefined)[] = []; | ||
// We have to inject node into the args object. | ||
@@ -853,2 +985,5 @@ // components are already holding a reference to the args object. | ||
for (const state of pendingAttachStates) { | ||
previousNodes.push( | ||
state.component.afterRender ? state.args.element.node : undefined | ||
); | ||
state.args.element.node = node; | ||
@@ -895,15 +1030,13 @@ } | ||
} | ||
} | ||
/* | ||
Compare old props and new props. | ||
We don't rerender if props remain the same. | ||
*/ | ||
function havePropsChanged(newProps: any, oldProps: any) { | ||
const oldKeys = Object.keys(oldProps); | ||
const newKeys = Object.keys(newProps); | ||
return ( | ||
oldKeys.length !== newKeys.length || | ||
oldKeys.some((key) => oldProps[key] !== newProps[key]) | ||
); | ||
// Run afterRender() is defined. | ||
previousNodes.forEach((previousNode, i) => { | ||
const state = pendingAttachStates[i]; | ||
if (state.component.afterRender) { | ||
state.component.afterRender(state.props, { | ||
...pendingAttachStates[i].args, | ||
previousNode, | ||
}); | ||
} | ||
}); | ||
} | ||
@@ -918,11 +1051,29 @@ | ||
) { | ||
let parentElement = isString(container) | ||
let parentElement = (isString(container) | ||
? env.document.querySelector(container) | ||
: container; | ||
: container) as HTMLElement; | ||
if (parentElement.nodeType !== ELEMENT_NODE_TYPE) { | ||
throw new Error( | ||
"The container argument to mount() should be an HTML element." | ||
); | ||
} | ||
if (parentElement) { | ||
const { node } = internalRender(forgoNode, undefined, [], true); | ||
parentElement.appendChild(node); | ||
internalRender( | ||
forgoNode, | ||
{ | ||
type: "search", | ||
currentNodeIndex: 0, | ||
length: 0, | ||
parentElement, | ||
}, | ||
[] | ||
); | ||
} else { | ||
throw new Error(`Mount was called on a non-element (${parentElement}).`); | ||
throw new Error( | ||
`Mount was called on a non-element (${ | ||
typeof container === "string" ? container : container?.tagName | ||
}).` | ||
); | ||
} | ||
@@ -936,3 +1087,10 @@ } | ||
export function render(forgoNode: ForgoNode) { | ||
return internalRender(forgoNode, undefined, [], true); | ||
const renderResult = internalRender( | ||
forgoNode, | ||
{ | ||
type: "detached", | ||
}, | ||
[] | ||
); | ||
return { node: renderResult.nodes[0], nodes: renderResult.nodes }; | ||
} | ||
@@ -944,3 +1102,3 @@ | ||
Given only a DOM element, how do we know what component to render? | ||
Given only a DOM element, how do we know what component to render? | ||
We'll fetch all that information from the state information stored on the element. | ||
@@ -954,34 +1112,70 @@ | ||
fullRerender = true | ||
) { | ||
): RenderResult { | ||
if (element && element.node) { | ||
const state = getForgoState(element.node); | ||
if (state) { | ||
const componentState = state.components[element.componentIndex]; | ||
const parentElement = element.node.parentElement; | ||
if (parentElement !== null) { | ||
const state = getForgoState(element.node); | ||
if (state) { | ||
const componentState = state.components[element.componentIndex]; | ||
const effectiveProps = | ||
typeof props !== "undefined" ? props : componentState.props; | ||
const effectiveProps = | ||
typeof props !== "undefined" ? props : componentState.props; | ||
if ( | ||
!componentState.component.shouldUpdate || | ||
componentState.component.shouldUpdate( | ||
effectiveProps, | ||
componentState.props | ||
) | ||
) { | ||
const forgoNode = componentState.component.render( | ||
effectiveProps, | ||
componentState.args | ||
); | ||
const statesToAttach = state.components | ||
.slice(0, element.componentIndex) | ||
.concat({ | ||
if ( | ||
!componentState.component.shouldUpdate || | ||
componentState.component.shouldUpdate( | ||
effectiveProps, | ||
componentState.props | ||
) | ||
) { | ||
const newComponentState = { | ||
...componentState, | ||
props: effectiveProps, | ||
}); | ||
}; | ||
internalRender(forgoNode, element.node, statesToAttach, fullRerender); | ||
const statesToAttach = state.components | ||
.slice(0, element.componentIndex) | ||
.concat(newComponentState); | ||
const forgoNode = componentState.component.render( | ||
effectiveProps, | ||
componentState.args | ||
); | ||
const nodeIndex = Array.from(parentElement.childNodes).findIndex( | ||
(x) => x === element.node | ||
); | ||
const insertionOptions: SearchableNodeInsertionOptions = { | ||
type: "search", | ||
currentNodeIndex: nodeIndex, | ||
length: componentState.numNodes, | ||
parentElement, | ||
}; | ||
return renderComponentAndRemoveStaleNodes( | ||
forgoNode, | ||
insertionOptions, | ||
statesToAttach, | ||
newComponentState | ||
); | ||
} | ||
// shouldUpdate() returned false | ||
else { | ||
const allNodes = Array.from(parentElement.childNodes); | ||
const indexOfNode = allNodes.findIndex((x) => x === element.node); | ||
return { | ||
nodes: allNodes.slice( | ||
indexOfNode, | ||
indexOfNode + componentState.numNodes | ||
), | ||
}; | ||
} | ||
} else { | ||
throw new Error(`Missing forgo state on node.`); | ||
} | ||
} else { | ||
throw new Error(`Missing forgo state on node.`); | ||
throw new Error( | ||
`Rerender was called on a node without a parent element.` | ||
); | ||
} | ||
@@ -993,5 +1187,15 @@ } else { | ||
function flatten<T>(array: (T | T[])[], ret: T[] = []): T[] { | ||
for (const entry of array) { | ||
if (Array.isArray(entry)) { | ||
function flatten( | ||
itemOrItems: ForgoNode | ForgoNode[], | ||
ret: ForgoNode[] = [] | ||
): ForgoNode[] { | ||
const items = Array.isArray(itemOrItems) | ||
? itemOrItems | ||
: isForgoFragment(itemOrItems) | ||
? Array.isArray(itemOrItems.props.children) | ||
? itemOrItems.props.children | ||
: [itemOrItems.props.children] | ||
: [itemOrItems]; | ||
for (const entry of items) { | ||
if (Array.isArray(entry) || isForgoFragment(entry)) { | ||
flatten(entry, ret); | ||
@@ -1028,2 +1232,12 @@ } else { | ||
function isForgoCustomComponent( | ||
node: ForgoNode | ||
): node is ForgoCustomComponentElement<any> { | ||
return isForgoElement(node) && typeof node.type !== "string"; | ||
} | ||
function isForgoFragment(node: ForgoNode): node is ForgoFragment { | ||
return isForgoElement(node) && node.type === Fragment; | ||
} | ||
/* | ||
@@ -1039,4 +1253,8 @@ Get the state (NodeAttachedState) saved into an element. | ||
*/ | ||
function getExistingForgoState(node: Required<ChildNode>): NodeAttachedState { | ||
return node.__forgo; | ||
function getExistingForgoState(node: ChildNode): NodeAttachedState { | ||
if (node.__forgo) { | ||
return node.__forgo; | ||
} else { | ||
throw new Error("Missing state in node."); | ||
} | ||
} | ||
@@ -1043,0 +1261,0 @@ |
Sorry, the diff of this file is not supported yet
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
113609
2008
466