Comparing version 2.2.3 to 3.0.0
@@ -65,2 +65,6 @@ export declare type ForgoRef<T> = { | ||
export declare type ForgoPrimitiveNode = ForgoNonEmptyPrimitiveNode | null | undefined; | ||
/** | ||
* Anything renderable by Forgo, whether from an external source (e.g., | ||
* component.render() output), or internally (e.g., DOM nodes) | ||
*/ | ||
export declare type ForgoNode = ForgoPrimitiveNode | ForgoElement<any> | ForgoFragment; | ||
@@ -104,11 +108,31 @@ export declare type NodeAttachedComponentState<TProps> = { | ||
}; | ||
/** | ||
* Nodes will be created as detached DOM nodes, and will not be attached to the parent | ||
*/ | ||
export declare type DetachedNodeInsertionOptions = { | ||
type: "detached"; | ||
}; | ||
/** | ||
* Instructs the renderer to search for an existing node to modify or replace, | ||
* before creating a new node. | ||
*/ | ||
export declare type SearchableNodeInsertionOptions = { | ||
type: "search"; | ||
/** | ||
* The element that holds the previously-rendered version of this component | ||
*/ | ||
parentElement: Element; | ||
/** | ||
* Where under the parent's children to find the start of this component | ||
*/ | ||
currentNodeIndex: number; | ||
/** | ||
* How many elements after currentNodeIndex belong to the element we're | ||
* searching | ||
*/ | ||
length: number; | ||
}; | ||
/** | ||
* Decides how the called function attaches nodes to the supplied parent | ||
*/ | ||
export declare type NodeInsertionOptions = DetachedNodeInsertionOptions | SearchableNodeInsertionOptions; | ||
@@ -141,2 +165,6 @@ export declare type UnloadableChildNode = { | ||
export declare const h: typeof createElement; | ||
/** | ||
* Creates everything needed to run forgo, wrapped in a closure holding e.g., | ||
* JSDOM-specific environment overrides used in tests | ||
*/ | ||
export declare function createForgoInstance(customEnv: any): { | ||
@@ -143,0 +171,0 @@ mount: (forgoNode: ForgoNode, container: Element | string | null) => RenderResult; |
@@ -16,4 +16,7 @@ // Since we'll set any attribute the user passes us, we need to be sure not to | ||
/* | ||
The element types we care about. | ||
As defined by the standards. | ||
These come from the browser's Node interface, which defines an enum of node | ||
types. We'd like to just reference Node.<whatever>, but JSDOM makes us jump | ||
through hoops to do that because it hates adding new globals. Getting around | ||
that is more complex, and more bytes on the wire, than just hardcoding the | ||
constants we care about. | ||
*/ | ||
@@ -52,2 +55,6 @@ const ELEMENT_NODE_TYPE = 1; | ||
} | ||
/** | ||
* Creates everything needed to run forgo, wrapped in a closure holding e.g., | ||
* JSDOM-specific environment overrides used in tests | ||
*/ | ||
export function createForgoInstance(customEnv) { | ||
@@ -60,9 +67,13 @@ var _a; | ||
}; | ||
/* | ||
This is the main render function. forgoNode is the node to render. | ||
insertionOptions specify which nodes need to be replaced by the new node(s), | ||
or whether the new node should be created detached from the DOM (without replacement). | ||
pendingAttachStates is the list of Component State objects which will be attached to the element. | ||
*/ | ||
/** | ||
* This is the main render function. | ||
* @param forgoNode The node to render. Can be any value renderable by Forgo, | ||
* not just DOM nodes. | ||
* @param insertionOptions Which nodes need to be replaced by the new | ||
* node(s), or whether the new node should be created detached from the DOM | ||
* (without replacement). | ||
* @param pendingAttachStates The list of Component State objects which will | ||
* be attached to the element. | ||
*/ | ||
function internalRender(forgoNode, insertionOptions, pendingAttachStates, mountOnPreExistingDOM) { | ||
@@ -94,3 +105,3 @@ // Array of Nodes | ||
*/ | ||
function renderNothing(forgoNode, insertionOptions, pendingAttachStates, mountOnPreExistingDOM) { | ||
function renderNothing(_forgoNode, _insertionOptions, _pendingAttachStates, _mountOnPreExistingDOM) { | ||
return { nodes: [] }; | ||
@@ -110,24 +121,18 @@ } | ||
*/ | ||
function renderText(forgoNode, insertionOptions, pendingAttachStates, mountOnPreExistingDOM) { | ||
function renderText(forgoNode, insertionOptions, pendingAttachStates, | ||
// TODO: Any reason to keep this parameter? | ||
_mountOnPreExistingDOM) { | ||
var _a; | ||
// We need to create a detached node. | ||
if (insertionOptions.type === "detached") { | ||
// Text nodes will always be recreated. | ||
const textNode = env.document.createTextNode(stringOfPrimitiveNode(forgoNode)); | ||
syncStateAndProps(forgoNode, textNode, true, pendingAttachStates, undefined); | ||
return { nodes: [textNode] }; | ||
} | ||
// Text nodes will always be recreated. | ||
const textNode = env.document.createTextNode(stringOfPrimitiveNode(forgoNode)); | ||
let oldComponentState = undefined; | ||
// We have to find a node to replace. | ||
else { | ||
// Text nodes will always be recreated. | ||
const textNode = env.document.createTextNode(stringOfPrimitiveNode(forgoNode)); | ||
if (insertionOptions.type === "search") { | ||
const childNodes = insertionOptions.parentElement.childNodes; | ||
// If we're searching in a list, we replace if the current node is a text node. | ||
const childNodes = insertionOptions.parentElement.childNodes; | ||
if (insertionOptions.length) { | ||
let targetNode = childNodes[insertionOptions.currentNodeIndex]; | ||
const targetNode = childNodes[insertionOptions.currentNodeIndex]; | ||
if (targetNode.nodeType === TEXT_NODE_TYPE) { | ||
targetNode.replaceWith(textNode); | ||
const oldComponentState = (_a = getForgoState(targetNode)) === null || _a === void 0 ? void 0 : _a.components; | ||
syncStateAndProps(forgoNode, textNode, true, pendingAttachStates, oldComponentState); | ||
return { nodes: [textNode] }; | ||
oldComponentState = (_a = getForgoState(targetNode)) === null || _a === void 0 ? void 0 : _a.components; | ||
} | ||
@@ -137,24 +142,20 @@ else { | ||
insertionOptions.parentElement.insertBefore(textNode, nextNode); | ||
syncStateAndProps(forgoNode, textNode, true, pendingAttachStates, undefined); | ||
return { nodes: [textNode] }; | ||
} | ||
} | ||
// There are no target nodes available. | ||
else if (childNodes.length === 0 || | ||
insertionOptions.currentNodeIndex === 0) { | ||
insertionOptions.parentElement.prepend(textNode); | ||
} | ||
else { | ||
const childNodes = insertionOptions.parentElement.childNodes; | ||
if (childNodes.length === 0 || | ||
insertionOptions.currentNodeIndex === 0) { | ||
insertionOptions.parentElement.prepend(textNode); | ||
} | ||
else { | ||
const nextNode = childNodes[insertionOptions.currentNodeIndex]; | ||
insertionOptions.parentElement.insertBefore(textNode, nextNode); | ||
} | ||
syncStateAndProps(forgoNode, textNode, true, pendingAttachStates, undefined); | ||
return { nodes: [textNode] }; | ||
const nextNode = childNodes[insertionOptions.currentNodeIndex]; | ||
insertionOptions.parentElement.insertBefore(textNode, nextNode); | ||
} | ||
} | ||
syncStateAndProps(forgoNode, textNode, true, pendingAttachStates, oldComponentState); | ||
return { nodes: [textNode] }; | ||
} | ||
/* | ||
Render a DOM element. | ||
Render a DOM element. Will find + update an existing DOM element (if | ||
appropriate), or insert a new element. | ||
@@ -183,11 +184,8 @@ Such as in the render function below: | ||
} | ||
else { | ||
return addElement(insertionOptions.parentElement, childNodes[insertionOptions.currentNodeIndex]); | ||
} | ||
} | ||
else { | ||
return addElement(insertionOptions.parentElement, childNodes[insertionOptions.currentNodeIndex]); | ||
} | ||
return addElement(insertionOptions.parentElement, childNodes[insertionOptions.currentNodeIndex]); | ||
} | ||
function renderChildNodes(parentElement) { | ||
// If the user gave us exact HTML to stuff into this parent, we can | ||
// skip/ignore the usual rendering logic | ||
if (forgoElement.props.dangerouslySetInnerHTML) { | ||
@@ -198,29 +196,65 @@ parentElement.innerHTML = | ||
else { | ||
const forgoChildrenObj = forgoElement.props.children; | ||
// Children will not be an array if single item. | ||
const forgoChildren = flatten((Array.isArray(forgoChildrenObj) | ||
? forgoChildrenObj | ||
: [forgoChildrenObj]).filter((x) => x !== undefined && x !== null)); | ||
let currentChildNodeIndex = 0; | ||
// Coerce children to always be an array, for simplicity | ||
const forgoChildren = flatten([forgoElement.props.children]).filter( | ||
// Children may or may not be specified | ||
(x) => x !== undefined && x !== null); | ||
// Make sure that if the user prepends non-Forgo DOM children under this | ||
// parent that we start with the correct offset, otherwise we'll do DOM | ||
// transformations that don't make any sense for the given input. | ||
const firstForgoChildIndex = Array.from(parentElement.childNodes).findIndex((child) => getForgoState(child)); | ||
// Each node we render will push any leftover children further down the | ||
// parent's list of children. After rendering everything, we can clean | ||
// up anything extra. We'll know what's extra because all nodes we want | ||
// to preserve come before this index. | ||
let lastRenderedNodeIndex = firstForgoChildIndex === -1 ? 0 : firstForgoChildIndex; | ||
for (const forgoChild of forgoChildren) { | ||
const { nodes } = internalRender(forgoChild, { | ||
const { nodes: nodesAfterRender } = internalRender(forgoChild, { | ||
type: "search", | ||
parentElement, | ||
currentNodeIndex: currentChildNodeIndex, | ||
length: parentElement.childNodes.length - currentChildNodeIndex, | ||
currentNodeIndex: lastRenderedNodeIndex, | ||
length: parentElement.childNodes.length - lastRenderedNodeIndex, | ||
}, [], mountOnPreExistingDOM); | ||
currentChildNodeIndex += nodes.length; | ||
// Continue down the children list to wherever's right after the stuff | ||
// we just added. Because users are allowed to add arbitrary stuff to | ||
// the DOM manually, we can't just jump by the count of rendered | ||
// elements, since that's the count of *managed* elements, which might | ||
// be interspersed with unmanaged elements that we also need to skip | ||
// past. | ||
if (nodesAfterRender.length) { | ||
while (parentElement.childNodes[lastRenderedNodeIndex] !== | ||
nodesAfterRender[nodesAfterRender.length - 1]) { | ||
lastRenderedNodeIndex += 1; | ||
} | ||
// Move the counter *past* the last node we inserted. E.g., if we just | ||
// inserted our first node, we need to increment from 0 -> 1, where | ||
// we'll start searching for the next thing we insert | ||
lastRenderedNodeIndex += 1; | ||
// If we're updating an existing DOM element, it's possible that the | ||
// user manually added some DOM nodes somewhere in the middle of our | ||
// managed nodes. If that happened, we need to scan forward until we | ||
// pass them and find the next managed node, which we'll use as the | ||
// starting point for whatever we render next. We still need the +1 | ||
// above to make sure we always progress the index, in case this is | ||
// our first render pass and there's nothing to scan forward to. | ||
while (lastRenderedNodeIndex < parentElement.childNodes.length) { | ||
if (getForgoState(parentElement.childNodes[lastRenderedNodeIndex])) { | ||
break; | ||
} | ||
lastRenderedNodeIndex += 1; | ||
} | ||
} | ||
} | ||
// Get rid the the remaining nodes. | ||
const nodesToRemove = sliceNodes(parentElement.childNodes, currentChildNodeIndex, parentElement.childNodes.length); | ||
if (nodesToRemove.length) { | ||
markNodesForUnloading(nodesToRemove); | ||
} | ||
// Remove all nodes that don't correspond to the rendered output of a | ||
// live component | ||
markNodesForUnloading(parentElement.childNodes, lastRenderedNodeIndex, parentElement.childNodes.length); | ||
} | ||
} | ||
/** | ||
* If we're updating an element that was rendered in a previous render, | ||
* reuse the same DOM element. Just sync its children and attributes. | ||
*/ | ||
function renderExistingElement(insertAt, childNodes, insertionOptions) { | ||
var _a; | ||
// Get rid of unwanted nodes. | ||
const nodesToRemove = sliceNodes(childNodes, insertionOptions.currentNodeIndex, insertAt); | ||
markNodesForUnloading(nodesToRemove); | ||
markNodesForUnloading(childNodes, insertionOptions.currentNodeIndex, insertAt); | ||
const targetElement = childNodes[insertionOptions.currentNodeIndex]; | ||
@@ -254,30 +288,17 @@ const oldComponentState = (_a = getForgoState(targetElement)) === null || _a === void 0 ? void 0 : _a.components; | ||
const componentIndex = pendingAttachStates.length; | ||
if ( | ||
// We need to create a detached node. | ||
if (insertionOptions.type === "detached") { | ||
return addComponent(); | ||
} | ||
// We have to find a node to replace. | ||
else { | ||
if (insertionOptions.length) { | ||
if (mountOnPreExistingDOM) { | ||
return addComponent(); | ||
} | ||
else { | ||
const childNodes = insertionOptions.parentElement.childNodes; | ||
const searchResult = findReplacementCandidateForComponent(forgoElement, insertionOptions.parentElement, insertionOptions.currentNodeIndex, insertionOptions.length, pendingAttachStates.length); | ||
if (searchResult.found) { | ||
return renderExistingComponent(searchResult.index, childNodes, insertionOptions); | ||
} | ||
// No matching node found. | ||
else { | ||
return addComponent(); | ||
} | ||
} | ||
insertionOptions.type !== "detached" && | ||
// We have to find a node to replace. | ||
insertionOptions.length && | ||
!mountOnPreExistingDOM) { | ||
const childNodes = insertionOptions.parentElement.childNodes; | ||
const searchResult = findReplacementCandidateForComponent(forgoElement, insertionOptions.parentElement, insertionOptions.currentNodeIndex, insertionOptions.length, pendingAttachStates.length); | ||
if (searchResult.found) { | ||
return renderExistingComponent(searchResult.index, childNodes, insertionOptions); | ||
} | ||
// No nodes in target node list. | ||
// Nothing to unload. | ||
else { | ||
return addComponent(); | ||
} | ||
} | ||
// No nodes in target node list, or no matching node found. | ||
// Nothing to unload. | ||
return addComponent(); | ||
function renderExistingComponent(insertAt, childNodes, insertionOptions) { | ||
@@ -288,6 +309,5 @@ const targetNode = childNodes[insertAt]; | ||
// Get rid of unwanted nodes. | ||
const nodesToRemove = sliceNodes(childNodes, insertionOptions.currentNodeIndex, insertAt); | ||
markNodesForUnloading(nodesToRemove); | ||
markNodesForUnloading(childNodes, insertionOptions.currentNodeIndex, insertAt); | ||
if (!componentState.component.shouldUpdate || | ||
componentState.component.shouldUpdate(forgoElement.props, componentState.props)) { | ||
!componentState.component.shouldUpdate(forgoElement.props, componentState.props)) { | ||
// Since we have compatible state already stored, | ||
@@ -397,4 +417,3 @@ // we'll push the savedComponentState into pending states for later attachment. | ||
const newIndex = insertionOptions.currentNodeIndex + renderResult.nodes.length; | ||
const nodesToRemove = sliceNodes(insertionOptions.parentElement.childNodes, newIndex, newIndex + componentState.nodes.length - numNodesReused); | ||
markNodesForUnloading(nodesToRemove); | ||
markNodesForUnloading(insertionOptions.parentElement.childNodes, newIndex, newIndex + componentState.nodes.length - numNodesReused); | ||
// In case we rendered an array, set the node to the first node. | ||
@@ -452,13 +471,27 @@ // We do this because args.element.node would be set to the last node otherwise. | ||
} | ||
/* | ||
This doesn't unmount components attached to these nodes, | ||
but moves the node itself from the DOM to parentNode.__forgo_deletedNodes. | ||
We sort of "mark" it for deletion, but it may be resurrected when a keyed, | ||
off-order forgo node matches a deleted node. | ||
*/ | ||
function markNodesForUnloading(nodes) { | ||
if (nodes.length) { | ||
const parentElement = nodes[0].parentElement; | ||
/** | ||
* This doesn't unmount components attached to these nodes, but moves the node | ||
* itself from the DOM to parentNode.__forgo_deletedNodes. We sort of "mark" | ||
* it for deletion, but it may be resurrected if it's matched by a keyed forgo | ||
* node that has been reordered. | ||
* | ||
* Nodes in between `from` and `to` (not inclusive of `to`) will be marked for | ||
* unloading. Use `unloadMarkedNodes()` to actually unload the nodes once | ||
* we're sure we don't need to resurrect them. | ||
* | ||
* We don't want to remove DOM nodes that aren't owned by Forgo. I.e., if the | ||
* user grabs a reference to a DOM element and manually adds children under | ||
* it, we don't want to remove those children. That'll mess up e.g., charting | ||
* libraries. | ||
*/ | ||
function markNodesForUnloading(nodes, from, to) { | ||
const nodesToRemove = sliceNodes(nodes, from, to); | ||
if (nodesToRemove.length) { | ||
const parentElement = nodesToRemove[0].parentElement; | ||
const deletedNodes = getDeletedNodes(parentElement); | ||
for (const node of nodes) { | ||
for (const node of nodesToRemove) { | ||
// If the consuming application has manually mucked with the DOM don't | ||
// remove things it added | ||
if (!getForgoState(node)) | ||
continue; | ||
node.remove(); | ||
@@ -565,8 +598,8 @@ deletedNodes.push({ node }); | ||
} | ||
/* | ||
When we try to find replacement candidates for DOM nodes, | ||
we try to: | ||
a) match by the key | ||
b) match by the tagname | ||
*/ | ||
/** | ||
* When we try to find replacement candidates for DOM nodes, | ||
* we try to: | ||
* a) match by the key | ||
* b) match by the tagname | ||
*/ | ||
function findReplacementCandidateForElement(forgoElement, parentElement, searchFrom, length) { | ||
@@ -578,6 +611,10 @@ const nodes = parentElement.childNodes; | ||
const stateOnNode = getForgoState(node); | ||
if (forgoElement.key) { | ||
if ((stateOnNode === null || stateOnNode === void 0 ? void 0 : stateOnNode.key) === forgoElement.key) { | ||
return { found: true, index: i }; | ||
} | ||
// If the user stuffs random elements into the DOM manually, we don't | ||
// want to treat them as replacement candidates - they should be left | ||
// alone. | ||
if (!stateOnNode) | ||
continue; | ||
if (forgoElement.key !== undefined && | ||
(stateOnNode === null || stateOnNode === void 0 ? void 0 : stateOnNode.key) === forgoElement.key) { | ||
return { found: true, index: i }; | ||
} | ||
@@ -588,3 +625,3 @@ else { | ||
if (node.tagName.toLowerCase() === forgoElement.type && | ||
(!stateOnNode || !stateOnNode.key)) { | ||
!(stateOnNode === null || stateOnNode === void 0 ? void 0 : stateOnNode.key)) { | ||
return { found: true, index: i }; | ||
@@ -596,3 +633,3 @@ } | ||
// Let's check deleted nodes as well. | ||
if (forgoElement.key) { | ||
if (forgoElement.key !== undefined) { | ||
const deletedNodes = getDeletedNodes(parentElement); | ||
@@ -619,8 +656,8 @@ for (let i = 0; i < deletedNodes.length; i++) { | ||
} | ||
/* | ||
When we try to find replacement candidates for Components, | ||
we try to: | ||
a) match by the key | ||
b) match by the component constructor | ||
*/ | ||
/** | ||
* When we try to find replacement candidates for Components, | ||
* we try to: | ||
* a) match by the key | ||
* b) match by the component constructor | ||
*/ | ||
function findReplacementCandidateForComponent(forgoElement, parentElement, searchFrom, length, componentIndex) { | ||
@@ -632,3 +669,3 @@ const nodes = parentElement.childNodes; | ||
if (stateOnNode && stateOnNode.components.length > componentIndex) { | ||
if (forgoElement.key) { | ||
if (forgoElement.key !== undefined) { | ||
if (stateOnNode.components[componentIndex].ctor === forgoElement.type && | ||
@@ -650,3 +687,4 @@ stateOnNode.components[componentIndex].key === forgoElement.key) { | ||
if (stateOnNode && stateOnNode.components.length > componentIndex) { | ||
if (stateOnNode.components[componentIndex].ctor === forgoElement.type && stateOnNode.components[componentIndex].key === forgoElement.key) { | ||
if (stateOnNode.components[componentIndex].ctor === forgoElement.type && | ||
stateOnNode.components[componentIndex].key === forgoElement.key) { | ||
return true; | ||
@@ -658,3 +696,3 @@ } | ||
// Let's check deleted nodes as well. | ||
if (forgoElement.key) { | ||
if (forgoElement.key !== undefined) { | ||
const deletedNodes = getDeletedNodes(parentElement); | ||
@@ -693,6 +731,6 @@ for (let i = 0; i < deletedNodes.length; i++) { | ||
} | ||
/* | ||
Attach props from the forgoElement on to the DOM node. | ||
We also need to attach states from pendingAttachStates | ||
*/ | ||
/** | ||
* Attach props from the forgoElement onto the DOM node. We also need to attach | ||
* states from pendingAttachStates | ||
*/ | ||
function attachProps(forgoNode, node, isNewNode, pendingAttachStates) { | ||
@@ -869,3 +907,3 @@ var _a; | ||
const originalComponentState = state.components[element.componentIndex]; | ||
const effectiveProps = props !== undefined ? props : originalComponentState.props; | ||
const effectiveProps = props !== null && props !== void 0 ? props : originalComponentState.props; | ||
if (!originalComponentState.component.shouldUpdate || | ||
@@ -1103,18 +1141,17 @@ originalComponentState.component.shouldUpdate(effectiveProps, originalComponentState.props)) { | ||
} | ||
/* parentElements.childNodes is not an array. A slice() for it. */ | ||
/** | ||
* node.childNodes is some funky data structure that's not really not an array, | ||
* so we can't just slice it like normal | ||
*/ | ||
function sliceNodes(nodes, from, to) { | ||
const result = []; | ||
for (let i = from; i < to; i++) { | ||
result.push(nodes[i]); | ||
} | ||
return result; | ||
return Array.from(nodes).slice(from, to); | ||
} | ||
/* parentElements.childNodes is not an array. A findIndex() for it. */ | ||
/** | ||
* node.childNodes is some funky data structure that's not really not an array, | ||
* so we can't just search for the value like normal | ||
*/ | ||
function findNodeIndex(nodes, element) { | ||
for (let i = 0; i < nodes.length; i++) { | ||
if (nodes[i] === element) { | ||
return i; | ||
} | ||
} | ||
return -1; | ||
if (!element) | ||
return -1; | ||
return Array.from(nodes).indexOf(element); | ||
} | ||
@@ -1121,0 +1158,0 @@ /* JSX Types */ |
{ | ||
"name": "forgo", | ||
"version": "2.2.3", | ||
"version": "3.0.0", | ||
"main": "./dist/index.js", | ||
@@ -17,2 +17,3 @@ "type": "module", | ||
"@types/should": "^13.0.0", | ||
"@types/source-map-support": "^0.5.4", | ||
"esbuild": "^0.14.25", | ||
@@ -23,2 +24,3 @@ "jsdom": "^19.0.0", | ||
"should": "^13.2.3", | ||
"source-map-support": "^0.5.21", | ||
"typescript": "^4.6.2" | ||
@@ -25,0 +27,0 @@ }, |
@@ -588,2 +588,68 @@ # forgo | ||
## Manually adding elements to the DOM | ||
Forgo allows you to insert elements into the DOM, inside a Forgo component, | ||
using the vanilla JS DOM API. These elements will be completely unmanaged and | ||
ignored by Forgo. This is useful for enabling charting libraries, such as D3. | ||
If you add unmanaged elements as siblings to managed nodes, Forgo pushes the | ||
unmanaged nodes towards the bottom of the sibling list as managed nodes are | ||
added and removed. If you don't add/remove managed nodes, the unmanaged nodes | ||
will stay in their original positions. | ||
### ApexCharts example | ||
[Code Sandbox](https://codesandbox.io/s/forgo-apexcharts-demo-ulkqfe?file=/src/index.tsx) for this example | ||
```jsx | ||
const App = () => { | ||
const chartElement = {}; | ||
let interval; | ||
return { | ||
mount(_props, args) { | ||
const chartOptions = { | ||
chart: { | ||
type: "line" | ||
}, | ||
series: [ | ||
{ | ||
name: "sales", | ||
data: [30, 40, 35, 50, 49, 60, 70, 91, 125] | ||
} | ||
], | ||
xaxis: { | ||
categories: [1991, 1992, 1993, 1994, 1995, 1996, 1997, 1998, 1999] | ||
} | ||
}; | ||
const chart = new ApexCharts(chartElement.value, chartOptions); | ||
chart.render(); | ||
interval = setInterval(() => args.update(), 1_000); | ||
}, | ||
unmount() { | ||
if (interval) clearInterval(interval); | ||
}, | ||
render(_props, args) { | ||
const now = new Date(); | ||
return ( | ||
<div> | ||
<p> | ||
This component continually rerenders. Forgo manages the timestamp, | ||
but delegates control of the chart to ApexCharts. | ||
</p> | ||
<div ref={chartElement}></div> | ||
<p> | ||
The current time is:{" "} | ||
<time datetime={now.toISOString()}>{now.toLocaleString()}</time> | ||
</p> | ||
</div> | ||
); | ||
} | ||
}; | ||
}; | ||
``` | ||
## Try it out on CodeSandbox | ||
@@ -590,0 +656,0 @@ |
460
src/index.ts
@@ -133,2 +133,6 @@ /* | ||
/** | ||
* Anything renderable by Forgo, whether from an external source (e.g., | ||
* component.render() output), or internally (e.g., DOM nodes) | ||
*/ | ||
export type ForgoNode = ForgoPrimitiveNode | ForgoElement<any> | ForgoFragment; | ||
@@ -202,7 +206,5 @@ | ||
/* | ||
NodeInsertionOptions 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. | ||
*/ | ||
/** | ||
* Nodes will be created as detached DOM nodes, and will not be attached to the parent | ||
*/ | ||
export type DetachedNodeInsertionOptions = { | ||
@@ -212,9 +214,26 @@ type: "detached"; | ||
/** | ||
* Instructs the renderer to search for an existing node to modify or replace, | ||
* before creating a new node. | ||
*/ | ||
export type SearchableNodeInsertionOptions = { | ||
type: "search"; | ||
/** | ||
* The element that holds the previously-rendered version of this component | ||
*/ | ||
parentElement: Element; | ||
/** | ||
* Where under the parent's children to find the start of this component | ||
*/ | ||
currentNodeIndex: number; | ||
/** | ||
* How many elements after currentNodeIndex belong to the element we're | ||
* searching | ||
*/ | ||
length: number; | ||
}; | ||
/** | ||
* Decides how the called function attaches nodes to the supplied parent | ||
*/ | ||
export type NodeInsertionOptions = | ||
@@ -270,4 +289,7 @@ | DetachedNodeInsertionOptions | ||
/* | ||
The element types we care about. | ||
As defined by the standards. | ||
These come from the browser's Node interface, which defines an enum of node | ||
types. We'd like to just reference Node.<whatever>, but JSDOM makes us jump | ||
through hoops to do that because it hates adding new globals. Getting around | ||
that is more complex, and more bytes on the wire, than just hardcoding the | ||
constants we care about. | ||
*/ | ||
@@ -311,2 +333,6 @@ const ELEMENT_NODE_TYPE = 1; | ||
/** | ||
* Creates everything needed to run forgo, wrapped in a closure holding e.g., | ||
* JSDOM-specific environment overrides used in tests | ||
*/ | ||
export function createForgoInstance(customEnv: any) { | ||
@@ -319,9 +345,13 @@ const env: ForgoEnvType = customEnv; | ||
/* | ||
This is the main render function. forgoNode is the node to render. | ||
insertionOptions specify which nodes need to be replaced by the new node(s), | ||
or whether the new node should be created detached from the DOM (without replacement). | ||
pendingAttachStates is the list of Component State objects which will be attached to the element. | ||
*/ | ||
/** | ||
* This is the main render function. | ||
* @param forgoNode The node to render. Can be any value renderable by Forgo, | ||
* not just DOM nodes. | ||
* @param insertionOptions Which nodes need to be replaced by the new | ||
* node(s), or whether the new node should be created detached from the DOM | ||
* (without replacement). | ||
* @param pendingAttachStates The list of Component State objects which will | ||
* be attached to the element. | ||
*/ | ||
function internalRender( | ||
@@ -389,7 +419,7 @@ forgoNode: ForgoNode | ForgoNode[], | ||
function renderNothing( | ||
forgoNode: undefined | null, | ||
insertionOptions: NodeInsertionOptions, | ||
pendingAttachStates: NodeAttachedComponentState<any>[], | ||
mountOnPreExistingDOM: boolean | ||
) { | ||
_forgoNode: undefined | null, | ||
_insertionOptions: NodeInsertionOptions, | ||
_pendingAttachStates: NodeAttachedComponentState<any>[], | ||
_mountOnPreExistingDOM: boolean | ||
): RenderResult { | ||
return { nodes: [] }; | ||
@@ -414,81 +444,52 @@ } | ||
pendingAttachStates: NodeAttachedComponentState<any>[], | ||
mountOnPreExistingDOM: boolean | ||
// TODO: Any reason to keep this parameter? | ||
_mountOnPreExistingDOM: boolean | ||
): RenderResult { | ||
// We need to create a detached node. | ||
if (insertionOptions.type === "detached") { | ||
// Text nodes will always be recreated. | ||
const textNode: ChildNode = env.document.createTextNode( | ||
stringOfPrimitiveNode(forgoNode) | ||
); | ||
syncStateAndProps( | ||
forgoNode, | ||
textNode, | ||
true, | ||
pendingAttachStates, | ||
undefined | ||
); | ||
return { nodes: [textNode] }; | ||
} | ||
// Text nodes will always be recreated. | ||
const textNode: ChildNode = env.document.createTextNode( | ||
stringOfPrimitiveNode(forgoNode) | ||
); | ||
let oldComponentState: NodeAttachedComponentState<any>[] | undefined = | ||
undefined; | ||
// We have to find a node to replace. | ||
else { | ||
// Text nodes will always be recreated. | ||
const textNode: ChildNode = env.document.createTextNode( | ||
stringOfPrimitiveNode(forgoNode) | ||
); | ||
if (insertionOptions.type === "search") { | ||
const childNodes = insertionOptions.parentElement.childNodes; | ||
// If we're searching in a list, we replace if the current node is a text node. | ||
const childNodes = insertionOptions.parentElement.childNodes; | ||
if (insertionOptions.length) { | ||
let targetNode = childNodes[insertionOptions.currentNodeIndex]; | ||
const targetNode = childNodes[insertionOptions.currentNodeIndex]; | ||
if (targetNode.nodeType === TEXT_NODE_TYPE) { | ||
targetNode.replaceWith(textNode); | ||
const oldComponentState = getForgoState(targetNode)?.components; | ||
syncStateAndProps( | ||
forgoNode, | ||
textNode, | ||
true, | ||
pendingAttachStates, | ||
oldComponentState | ||
); | ||
return { nodes: [textNode] }; | ||
oldComponentState = getForgoState(targetNode)?.components; | ||
} else { | ||
const nextNode = childNodes[insertionOptions.currentNodeIndex]; | ||
insertionOptions.parentElement.insertBefore(textNode, nextNode); | ||
syncStateAndProps( | ||
forgoNode, | ||
textNode, | ||
true, | ||
pendingAttachStates, | ||
undefined | ||
); | ||
return { nodes: [textNode] }; | ||
} | ||
} | ||
// There are no target nodes available. | ||
else { | ||
const childNodes = insertionOptions.parentElement.childNodes; | ||
if ( | ||
childNodes.length === 0 || | ||
insertionOptions.currentNodeIndex === 0 | ||
) { | ||
insertionOptions.parentElement.prepend(textNode); | ||
} else { | ||
const nextNode = childNodes[insertionOptions.currentNodeIndex]; | ||
insertionOptions.parentElement.insertBefore(textNode, nextNode); | ||
} | ||
syncStateAndProps( | ||
forgoNode, | ||
textNode, | ||
true, | ||
pendingAttachStates, | ||
undefined | ||
); | ||
return { nodes: [textNode] }; | ||
else if ( | ||
childNodes.length === 0 || | ||
insertionOptions.currentNodeIndex === 0 | ||
) { | ||
insertionOptions.parentElement.prepend(textNode); | ||
} else { | ||
const nextNode = childNodes[insertionOptions.currentNodeIndex]; | ||
insertionOptions.parentElement.insertBefore(textNode, nextNode); | ||
} | ||
} | ||
syncStateAndProps( | ||
forgoNode, | ||
textNode, | ||
true, | ||
pendingAttachStates, | ||
oldComponentState | ||
); | ||
return { nodes: [textNode] }; | ||
} | ||
/* | ||
Render a DOM element. | ||
Render a DOM element. Will find + update an existing DOM element (if | ||
appropriate), or insert a new element. | ||
@@ -532,17 +533,14 @@ Such as in the render function below: | ||
); | ||
} else { | ||
return addElement( | ||
insertionOptions.parentElement, | ||
childNodes[insertionOptions.currentNodeIndex] | ||
); | ||
} | ||
} else { | ||
return addElement( | ||
insertionOptions.parentElement, | ||
childNodes[insertionOptions.currentNodeIndex] | ||
); | ||
} | ||
return addElement( | ||
insertionOptions.parentElement, | ||
childNodes[insertionOptions.currentNodeIndex] | ||
); | ||
} | ||
function renderChildNodes(parentElement: Element) { | ||
// If the user gave us exact HTML to stuff into this parent, we can | ||
// skip/ignore the usual rendering logic | ||
if (forgoElement.props.dangerouslySetInnerHTML) { | ||
@@ -552,16 +550,22 @@ parentElement.innerHTML = | ||
} else { | ||
const forgoChildrenObj = forgoElement.props.children; | ||
// Children will not be an array if single item. | ||
const forgoChildren = flatten( | ||
(Array.isArray(forgoChildrenObj) | ||
? forgoChildrenObj | ||
: [forgoChildrenObj] | ||
).filter((x) => x !== undefined && x !== null) | ||
// Coerce children to always be an array, for simplicity | ||
const forgoChildren = flatten([forgoElement.props.children]).filter( | ||
// Children may or may not be specified | ||
(x) => x !== undefined && x !== null | ||
); | ||
let currentChildNodeIndex = 0; | ||
// Make sure that if the user prepends non-Forgo DOM children under this | ||
// parent that we start with the correct offset, otherwise we'll do DOM | ||
// transformations that don't make any sense for the given input. | ||
const firstForgoChildIndex = Array.from( | ||
parentElement.childNodes | ||
).findIndex((child) => getForgoState(child)); | ||
// Each node we render will push any leftover children further down the | ||
// parent's list of children. After rendering everything, we can clean | ||
// up anything extra. We'll know what's extra because all nodes we want | ||
// to preserve come before this index. | ||
let lastRenderedNodeIndex = | ||
firstForgoChildIndex === -1 ? 0 : firstForgoChildIndex; | ||
for (const forgoChild of forgoChildren) { | ||
const { nodes } = internalRender( | ||
const { nodes: nodesAfterRender } = internalRender( | ||
forgoChild, | ||
@@ -571,4 +575,4 @@ { | ||
parentElement, | ||
currentNodeIndex: currentChildNodeIndex, | ||
length: parentElement.childNodes.length - currentChildNodeIndex, | ||
currentNodeIndex: lastRenderedNodeIndex, | ||
length: parentElement.childNodes.length - lastRenderedNodeIndex, | ||
}, | ||
@@ -578,18 +582,51 @@ [], | ||
); | ||
currentChildNodeIndex += nodes.length; | ||
// Continue down the children list to wherever's right after the stuff | ||
// we just added. Because users are allowed to add arbitrary stuff to | ||
// the DOM manually, we can't just jump by the count of rendered | ||
// elements, since that's the count of *managed* elements, which might | ||
// be interspersed with unmanaged elements that we also need to skip | ||
// past. | ||
if (nodesAfterRender.length) { | ||
while ( | ||
parentElement.childNodes[lastRenderedNodeIndex] !== | ||
nodesAfterRender[nodesAfterRender.length - 1] | ||
) { | ||
lastRenderedNodeIndex += 1; | ||
} | ||
// Move the counter *past* the last node we inserted. E.g., if we just | ||
// inserted our first node, we need to increment from 0 -> 1, where | ||
// we'll start searching for the next thing we insert | ||
lastRenderedNodeIndex += 1; | ||
// If we're updating an existing DOM element, it's possible that the | ||
// user manually added some DOM nodes somewhere in the middle of our | ||
// managed nodes. If that happened, we need to scan forward until we | ||
// pass them and find the next managed node, which we'll use as the | ||
// starting point for whatever we render next. We still need the +1 | ||
// above to make sure we always progress the index, in case this is | ||
// our first render pass and there's nothing to scan forward to. | ||
while (lastRenderedNodeIndex < parentElement.childNodes.length) { | ||
if ( | ||
getForgoState(parentElement.childNodes[lastRenderedNodeIndex]) | ||
) { | ||
break; | ||
} | ||
lastRenderedNodeIndex += 1; | ||
} | ||
} | ||
} | ||
// Get rid the the remaining nodes. | ||
const nodesToRemove = sliceNodes( | ||
// Remove all nodes that don't correspond to the rendered output of a | ||
// live component | ||
markNodesForUnloading( | ||
parentElement.childNodes, | ||
currentChildNodeIndex, | ||
lastRenderedNodeIndex, | ||
parentElement.childNodes.length | ||
); | ||
if (nodesToRemove.length) { | ||
markNodesForUnloading(nodesToRemove); | ||
} | ||
} | ||
} | ||
/** | ||
* If we're updating an element that was rendered in a previous render, | ||
* reuse the same DOM element. Just sync its children and attributes. | ||
*/ | ||
function renderExistingElement( | ||
@@ -601,3 +638,3 @@ insertAt: number, | ||
// Get rid of unwanted nodes. | ||
const nodesToRemove = sliceNodes( | ||
markNodesForUnloading( | ||
childNodes, | ||
@@ -607,3 +644,2 @@ insertionOptions.currentNodeIndex, | ||
); | ||
markNodesForUnloading(nodesToRemove); | ||
@@ -671,40 +707,29 @@ const targetElement = childNodes[ | ||
// We need to create a detached node. | ||
if (insertionOptions.type === "detached") { | ||
return addComponent(); | ||
} | ||
// We have to find a node to replace. | ||
else { | ||
if (insertionOptions.length) { | ||
if (mountOnPreExistingDOM) { | ||
return addComponent(); | ||
} else { | ||
const childNodes = insertionOptions.parentElement.childNodes; | ||
const searchResult = findReplacementCandidateForComponent( | ||
forgoElement, | ||
insertionOptions.parentElement, | ||
insertionOptions.currentNodeIndex, | ||
insertionOptions.length, | ||
pendingAttachStates.length | ||
); | ||
if ( | ||
// We need to create a detached node. | ||
insertionOptions.type !== "detached" && | ||
// We have to find a node to replace. | ||
insertionOptions.length && | ||
!mountOnPreExistingDOM | ||
) { | ||
const childNodes = insertionOptions.parentElement.childNodes; | ||
const searchResult = findReplacementCandidateForComponent( | ||
forgoElement, | ||
insertionOptions.parentElement, | ||
insertionOptions.currentNodeIndex, | ||
insertionOptions.length, | ||
pendingAttachStates.length | ||
); | ||
if (searchResult.found) { | ||
return renderExistingComponent( | ||
searchResult.index, | ||
childNodes, | ||
insertionOptions | ||
); | ||
} | ||
// No matching node found. | ||
else { | ||
return addComponent(); | ||
} | ||
} | ||
if (searchResult.found) { | ||
return renderExistingComponent( | ||
searchResult.index, | ||
childNodes, | ||
insertionOptions | ||
); | ||
} | ||
// No nodes in target node list. | ||
// Nothing to unload. | ||
else { | ||
return addComponent(); | ||
} | ||
} | ||
// No nodes in target node list, or no matching node found. | ||
// Nothing to unload. | ||
return addComponent(); | ||
@@ -721,3 +746,3 @@ function renderExistingComponent( | ||
// Get rid of unwanted nodes. | ||
const nodesToRemove = sliceNodes( | ||
markNodesForUnloading( | ||
childNodes, | ||
@@ -727,7 +752,6 @@ insertionOptions.currentNodeIndex, | ||
); | ||
markNodesForUnloading(nodesToRemove); | ||
if ( | ||
!componentState.component.shouldUpdate || | ||
componentState.component.shouldUpdate( | ||
!componentState.component.shouldUpdate( | ||
forgoElement.props, | ||
@@ -934,3 +958,3 @@ componentState.props | ||
const nodesToRemove = sliceNodes( | ||
markNodesForUnloading( | ||
insertionOptions.parentElement.childNodes, | ||
@@ -941,4 +965,2 @@ newIndex, | ||
markNodesForUnloading(nodesToRemove); | ||
// In case we rendered an array, set the node to the first node. | ||
@@ -1048,13 +1070,31 @@ // We do this because args.element.node would be set to the last node otherwise. | ||
/* | ||
This doesn't unmount components attached to these nodes, | ||
but moves the node itself from the DOM to parentNode.__forgo_deletedNodes. | ||
We sort of "mark" it for deletion, but it may be resurrected when a keyed, | ||
off-order forgo node matches a deleted node. | ||
*/ | ||
function markNodesForUnloading(nodes: ChildNode[]) { | ||
if (nodes.length) { | ||
const parentElement = nodes[0].parentElement as HTMLElement; | ||
/** | ||
* This doesn't unmount components attached to these nodes, but moves the node | ||
* itself from the DOM to parentNode.__forgo_deletedNodes. We sort of "mark" | ||
* it for deletion, but it may be resurrected if it's matched by a keyed forgo | ||
* node that has been reordered. | ||
* | ||
* Nodes in between `from` and `to` (not inclusive of `to`) will be marked for | ||
* unloading. Use `unloadMarkedNodes()` to actually unload the nodes once | ||
* we're sure we don't need to resurrect them. | ||
* | ||
* We don't want to remove DOM nodes that aren't owned by Forgo. I.e., if the | ||
* user grabs a reference to a DOM element and manually adds children under | ||
* it, we don't want to remove those children. That'll mess up e.g., charting | ||
* libraries. | ||
*/ | ||
function markNodesForUnloading( | ||
nodes: ArrayLike<ChildNode>, | ||
from: number, | ||
to: number | ||
) { | ||
const nodesToRemove = sliceNodes(nodes, from, to); | ||
if (nodesToRemove.length) { | ||
const parentElement = nodesToRemove[0].parentElement as HTMLElement; | ||
const deletedNodes = getDeletedNodes(parentElement); | ||
for (const node of nodes) { | ||
for (const node of nodesToRemove) { | ||
// If the consuming application has manually mucked with the DOM don't | ||
// remove things it added | ||
if (!getForgoState(node)) continue; | ||
node.remove(); | ||
@@ -1192,8 +1232,8 @@ deletedNodes.push({ node }); | ||
/* | ||
When we try to find replacement candidates for DOM nodes, | ||
we try to: | ||
a) match by the key | ||
b) match by the tagname | ||
*/ | ||
/** | ||
* When we try to find replacement candidates for DOM nodes, | ||
* we try to: | ||
* a) match by the key | ||
* b) match by the tagname | ||
*/ | ||
function findReplacementCandidateForElement<TProps>( | ||
@@ -1210,6 +1250,12 @@ forgoElement: ForgoDOMElement<TProps>, | ||
const stateOnNode = getForgoState(node); | ||
if (forgoElement.key) { | ||
if (stateOnNode?.key === forgoElement.key) { | ||
return { found: true, index: i }; | ||
} | ||
// If the user stuffs random elements into the DOM manually, we don't | ||
// want to treat them as replacement candidates - they should be left | ||
// alone. | ||
if (!stateOnNode) continue; | ||
if ( | ||
forgoElement.key !== undefined && | ||
stateOnNode?.key === forgoElement.key | ||
) { | ||
return { found: true, index: i }; | ||
} else { | ||
@@ -1220,3 +1266,3 @@ // If the candidate has a key defined, | ||
node.tagName.toLowerCase() === forgoElement.type && | ||
(!stateOnNode || !stateOnNode.key) | ||
!stateOnNode?.key | ||
) { | ||
@@ -1229,3 +1275,3 @@ return { found: true, index: i }; | ||
// Let's check deleted nodes as well. | ||
if (forgoElement.key) { | ||
if (forgoElement.key !== undefined) { | ||
const deletedNodes = getDeletedNodes(parentElement); | ||
@@ -1252,8 +1298,8 @@ for (let i = 0; i < deletedNodes.length; i++) { | ||
/* | ||
When we try to find replacement candidates for Components, | ||
we try to: | ||
a) match by the key | ||
b) match by the component constructor | ||
*/ | ||
/** | ||
* When we try to find replacement candidates for Components, | ||
* we try to: | ||
* a) match by the key | ||
* b) match by the component constructor | ||
*/ | ||
function findReplacementCandidateForComponent<TProps>( | ||
@@ -1271,3 +1317,3 @@ forgoElement: ForgoComponentElement<TProps>, | ||
if (stateOnNode && stateOnNode.components.length > componentIndex) { | ||
if (forgoElement.key) { | ||
if (forgoElement.key !== undefined) { | ||
if ( | ||
@@ -1297,3 +1343,6 @@ stateOnNode.components[componentIndex].ctor === forgoElement.type && | ||
if (stateOnNode && stateOnNode.components.length > componentIndex) { | ||
if (stateOnNode.components[componentIndex].ctor === forgoElement.type && stateOnNode.components[componentIndex].key === forgoElement.key) { | ||
if ( | ||
stateOnNode.components[componentIndex].ctor === forgoElement.type && | ||
stateOnNode.components[componentIndex].key === forgoElement.key | ||
) { | ||
return true; | ||
@@ -1306,3 +1355,3 @@ } | ||
// Let's check deleted nodes as well. | ||
if (forgoElement.key) { | ||
if (forgoElement.key !== undefined) { | ||
const deletedNodes = getDeletedNodes(parentElement); | ||
@@ -1312,7 +1361,3 @@ for (let i = 0; i < deletedNodes.length; i++) { | ||
if ( | ||
nodeBelongsToKeyedComponent( | ||
deletedNode, | ||
forgoElement, | ||
componentIndex | ||
) | ||
nodeBelongsToKeyedComponent(deletedNode, forgoElement, componentIndex) | ||
) { | ||
@@ -1357,6 +1402,6 @@ const nodesToResurrect: ChildNode[] = [deletedNode]; | ||
/* | ||
Attach props from the forgoElement on to the DOM node. | ||
We also need to attach states from pendingAttachStates | ||
*/ | ||
/** | ||
* Attach props from the forgoElement onto the DOM node. We also need to attach | ||
* states from pendingAttachStates | ||
*/ | ||
function attachProps( | ||
@@ -1572,4 +1617,3 @@ forgoNode: ForgoNode, | ||
const effectiveProps = | ||
props !== undefined ? props : originalComponentState.props; | ||
const effectiveProps = props ?? originalComponentState.props; | ||
@@ -1908,3 +1952,6 @@ if ( | ||
/* parentElements.childNodes is not an array. A slice() for it. */ | ||
/** | ||
* node.childNodes is some funky data structure that's not really not an array, | ||
* so we can't just slice it like normal | ||
*/ | ||
function sliceNodes( | ||
@@ -1915,10 +1962,9 @@ nodes: ArrayLike<ChildNode>, | ||
): ChildNode[] { | ||
const result: ChildNode[] = []; | ||
for (let i = from; i < to; i++) { | ||
result.push(nodes[i]); | ||
} | ||
return result; | ||
return Array.from(nodes).slice(from, to); | ||
} | ||
/* parentElements.childNodes is not an array. A findIndex() for it. */ | ||
/** | ||
* node.childNodes is some funky data structure that's not really not an array, | ||
* so we can't just search for the value like normal | ||
*/ | ||
function findNodeIndex( | ||
@@ -1928,8 +1974,4 @@ nodes: ArrayLike<ChildNode>, | ||
): number { | ||
for (let i = 0; i < nodes.length; i++) { | ||
if (nodes[i] === element) { | ||
return i; | ||
} | ||
} | ||
return -1; | ||
if (!element) return -1; | ||
return Array.from(nodes).indexOf(element); | ||
} | ||
@@ -1936,0 +1978,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
347885
17
4720
814
11