diff --git a/packages/diffhtml-components/lib/component.js b/packages/diffhtml-components/lib/component.js index 628210e3..71126ef9 100644 --- a/packages/diffhtml-components/lib/component.js +++ b/packages/diffhtml-components/lib/component.js @@ -1,6 +1,5 @@ import { EMPTY, - ComponentTreeCache, InstanceCache, Props, Transaction, @@ -14,12 +13,11 @@ import { $$unsubscribe, $$type, $$hooks, - $$insertAfter, } from './util/symbols'; import diff from './util/binding'; -import middleware from './middleware'; +import middleware from './lifecycle/middleware'; -const { outerHTML, innerHTML, createTree, release, Internals } = diff; +const { Internals } = diff; const { isArray } = Array; const { setPrototypeOf, defineProperty, keys, assign } = Object; const RenderDebounce = new WeakMap(); @@ -32,7 +30,7 @@ const getObserved = ({ defaultProps }) => defaultProps ? keys(defaultProps) : EMPTY.ARR; /** - * Creates the `component.props` object. + * Creates the props object for Web components. * * @param {any} domNode * @param {Props} existingProps @@ -47,7 +45,7 @@ const createProps = (domNode, existingProps = {}) => { }; /** - * Creates the `component.state` object. + * Creates the state object for Web components. * * @param {Component} domNode * @param {State} newState @@ -55,24 +53,21 @@ const createProps = (domNode, existingProps = {}) => { const createState = (domNode, newState) => assign({}, domNode.state, newState); /** - * Finds all VTrees associated with a component. + * Recreates the VTree used for diffing a stateful component. In the case of + * Web components, this will use the Shadow Root instead of creating a virtual + * fragment. * - * @param {VTree[]} childTrees - * @param {VTree} vTree + * @param {Component} instance - {Component} instance + * @param {Boolean} isWebComponent - Is this a web component? + * @return {VTree | HTMLElement} Recreated VTree including component references */ -const getChildTrees = (childTrees, vTree) => { - ComponentTreeCache.forEach((parentTree, childTree) => { - if (parentTree === vTree) { - ComponentTreeCache.delete(childTree); - - if (typeof childTree.rawNodeName !== 'function') { - childTrees.push(childTree); - } - else { - getChildTrees(childTrees, childTree); - } - } - }); +const createMountPoint = (instance, isWebComponent) => { + if (isWebComponent) { + return /** @type {any} */(instance).shadowRoot; + } + else { + return instance[$$vTree]; + } }; /** @@ -113,7 +108,6 @@ export default class Component { static unsubscribeMiddleware() { const unsubscribe = Component[$$unsubscribe]; unsubscribe && unsubscribe(); - ComponentTreeCache.clear(); InstanceCache.clear(); } @@ -136,7 +130,7 @@ export default class Component { /** @type {any} */ (instance).attachShadow({ mode: 'open' }); /** @type {any} */ (instance)[$$type] = 'web'; - } catch (e) { + } catch { // Not a Web Component. /** @type {any} */ (instance)[$$type] = 'class'; } @@ -159,13 +153,12 @@ export default class Component { } /** - * @param {Props} props - * @param {State} state + * @param {Props=} _props + * @param {State=} _state * - * @returns {VTree[] | VTree | undefined} + * @returns {VTree[] | VTree | any} */ - // @ts-ignore - render(props, state) { + render(_props, _state) { return undefined; } @@ -234,134 +227,76 @@ export default class Component { /** * Stateful render. Used when a component changes and needs to re-render - * itself. This is triggered on `setState` and `forceUpdate` calls. + * itself and the underlying tree it contains. This is triggered on + * `setState` and `forceUpdate` calls with class components and `createState` + * updates with function components. + * + * Web Components are easy to implement using the Shadow DOM to encapsulate + * the mount point using `innerHTML`. * - * @return {Transaction | undefined} + * React-like components are supported by recreating the previous component + * tree and comparing this to the new tree using `outerHTML`. + * + * @return {Transaction | unknown | undefined} */ [$$render]() { - // This is a WebComponent, so do something different. - if (this[$$type] === 'web') { - const oldProps = this.props; - const oldState = this.state; - - this.props = createProps(this, this.props); - this.state = createState(this, this.state); - - ActiveRenderState.push(this); - - if ($$hooks in this) { - this[$$hooks].i = 0; - } - - const transaction = /** @type {Transaction} */(innerHTML( - /** @type {any} */ (this).shadowRoot, - this.render(this.props, this.state), - )); - - ActiveRenderState.length = 0; - - this.componentDidUpdate(oldProps, oldState); - return transaction; - } + const vTree = /** @type {VTree=} */ this[$$vTree]; + const oldProps = this.props; + const oldState = this.state; - // Get the fragment tree associated with this component. This is used to - // lookup rendered children. - let vTree = this[$$vTree]; + // There are some slight differences between rendering a typical class or + // function based component and a web component. Web Components are + // rendered into the shadow DOM, while the class based components need + // extra logic to handle the invisible component boundaries. + const isWebComponent = this[$$type] === 'web'; /** - * Find all previously rendered top-level children associated to this - * component. This will be used to diff against the newly rendered - * elements. + * Recreate the existing tree including this component. This is not + * directly stored anywhere, as the VDOM tree is flattened, and must be + * recreated per render. * - * @type {VTree[]} + * @type {VTree | HTMLElement} */ - const childTrees = []; + const mount = createMountPoint(this, isWebComponent); - // Lookup all DOM nodes that were associated at the top level with this - // component. - vTree && getChildTrees(childTrees, vTree); + if (isWebComponent) { + this.props = createProps(this, this.props); + this.state = createState(this, this.state); + } - // Render directly from the Component. + // Make this component active and then synchronously render. ActiveRenderState.push(this); + // TBD Is this needed now that components track their own descendents? if ($$hooks in this) { this[$$hooks].i = 0; } - let renderTree = this.render(this.props, this.state); - ActiveRenderState.length = 0; - - // Do not render. - if (!renderTree) { - return; - } - - const renderTreeAsVTree = /** @type {VTree} */ (renderTree); + const rendered = this.render(this.props, this.state); - // Always compare a fragment to a fragment. If the renderTree was not - // wrapped, ensure it is here. - if (renderTreeAsVTree.nodeType !== 11) { - const isList = 'length' in renderTree; - const renderTreeAsList = /** @type {VTree[]} */ (renderTree); + const transaction = Internals.Transaction.create( + mount, + rendered, + { inner: true }, + ); - renderTree = createTree(isList ? renderTreeAsList : [renderTreeAsVTree]); + // Set the VTree attributes to match the current props and use this as the initial state for a re-render. + if (vTree) { + assign(vTree.attributes, this.props); + transaction.state.oldTree = vTree; + transaction.state.isDirty = false; } - // Put all the nodes together into a fragment for diffing. - const fragment = createTree(childTrees); - const tasks = [...Internals.defaultTasks]; - const syncTreesIndex = tasks.indexOf(Internals.tasks.syncTrees); - - // Inject a custom task after syncing has finished, but before patching has - // occured. This gives us time to add additional patch logic per render. - tasks.splice(syncTreesIndex + 1, 0, (/** @type {Transaction} */transaction) => { - let lastTree = null; - - // Reconcile all top-level replacements and additions. - for (let i = 0; i < fragment.childNodes.length; i++) { - const childTree = fragment.childNodes[i]; - - // Replace if the nodes are different. - if (childTree && childTrees[i] && childTrees[i] !== childTree) { - transaction.patches.push( - Internals.PATCH_TYPE.REPLACE_CHILD, - childTree, - childTrees[i], - ); - - lastTree = childTree; - } - // Add if there is no old Node. - else if (lastTree && !childTrees[i]) { - transaction.patches.push( - Internals.PATCH_TYPE.INSERT_BEFORE, - $$insertAfter, - childTree, - lastTree, - ); - - lastTree = childTree; - } - // Keep the old node. - else { - lastTree = childTrees[i]; - } - - ComponentTreeCache.set(childTree, vTree); - } + // Middleware and task changes can affect the return value, it's not always guarenteed to be the transaction + // this allows us to tap into that return value and remain consistent. + const retVal = transaction.start(); - return transaction; - }); - - // Compare the existing component node(s) to the new node(s). - const transaction = /** @type {Transaction} */(outerHTML(fragment, renderTree, { tasks })); - - // Empty the fragment after using. - fragment.childNodes.length = 0; - release(fragment); + // Reset the active state after rendering so we don't accidentally bleed + // into other components render cycle. + ActiveRenderState.length = 0; - this.componentDidUpdate(this.props, this.state); - return transaction; + this.componentDidUpdate(oldProps, oldState); + return retVal; } connectedCallback() { @@ -370,7 +305,7 @@ export default class Component { // This callback gets called during replace operations, there is no point // in re-rendering or creating a new shadow root due to this. - // Always do a full render when mounting, so that something is visible. + // This is the initial render for the Web Component this[$$render](); this.componentDidMount(); diff --git a/packages/diffhtml-components/lib/before-mount.js b/packages/diffhtml-components/lib/lifecycle/before-mount.js similarity index 82% rename from packages/diffhtml-components/lib/before-mount.js rename to packages/diffhtml-components/lib/lifecycle/before-mount.js index 2d15eb40..9d342910 100644 --- a/packages/diffhtml-components/lib/before-mount.js +++ b/packages/diffhtml-components/lib/lifecycle/before-mount.js @@ -1,9 +1,9 @@ -import componentWillUnmount from './lifecycle/component-will-unmount'; -import { invokeRef, invokeRefsForVTree } from './lifecycle/invoke-refs'; -import diff from './util/binding'; -import { Transaction } from './util/types'; +import componentWillUnmount from './component-will-unmount'; +import { invokeRef, invokeRefsForVTree } from './invoke-refs'; +import diff from '../util/binding'; +import { Transaction } from '../util/types'; -const { createNode, NodeCache, PATCH_TYPE, decodeEntities } = diff.Internals; +const { NodeCache, PATCH_TYPE, decodeEntities, createNode } = diff.Internals; const uppercaseEx = /[A-Z]/g; /** @@ -53,6 +53,7 @@ export default transaction => { } } + // TBD Remove because invokeRefs is handled in afterMount if (name === 'ref') { invokeRef(createNode(vTree), vTree); } @@ -69,6 +70,7 @@ export default transaction => { case PATCH_TYPE.REPLACE_CHILD: { const oldTree = patches[i + 2]; + invokeRefsForVTree(oldTree, null); componentWillUnmount(oldTree); i += 3; diff --git a/packages/diffhtml-components/lib/lifecycle/component-will-unmount.js b/packages/diffhtml-components/lib/lifecycle/component-will-unmount.js index 81f9574f..24c5e1f2 100644 --- a/packages/diffhtml-components/lib/lifecycle/component-will-unmount.js +++ b/packages/diffhtml-components/lib/lifecycle/component-will-unmount.js @@ -1,4 +1,4 @@ -import { ComponentTreeCache, InstanceCache, VTree } from '../util/types'; +import { InstanceCache, VTree } from '../util/types'; import diff from '../util/binding'; import { $$hooks } from '../util/symbols'; @@ -11,32 +11,26 @@ const { release, Internals } = diff; * @param {VTree} vTree - The respecting tree pointing to the component */ export default function componentWillUnmount(vTree) { - const componentTree = ComponentTreeCache.get(vTree); - - /** @type {VTree[]} */ - const childTrees = []; - - ComponentTreeCache.forEach((parentTree, childTree) => { - if (parentTree === componentTree) { - childTrees.push(childTree); - } - }); - const domNode = Internals.NodeCache.get(vTree); // Clean up attached Shadow DOM. if (domNode && /** @type {any} */ (domNode).shadowRoot) { release(/** @type {any} */ (domNode).shadowRoot); } + else { + Internals.memory.unprotectVTree(vTree); + } - vTree.childNodes.forEach(componentWillUnmount); + for (let i = 0; i < vTree.childNodes.length; i++) { + componentWillUnmount(vTree.childNodes[i]); + } - if (!InstanceCache.has(componentTree)) { + if (!InstanceCache.has(vTree)) { return; } - const instance = InstanceCache.get(componentTree); - InstanceCache.delete(componentTree); + const instance = InstanceCache.get(vTree); + InstanceCache.delete(vTree); // Empty out all hooks for gc. If using a stateless class or function, they // may not have this value set. @@ -45,13 +39,6 @@ export default function componentWillUnmount(vTree) { instance[$$hooks].i = 0; } - ComponentTreeCache.delete(vTree); - - // If there is a parent, ensure it is called recursively. - if (ComponentTreeCache.has(componentTree)) { - componentWillUnmount(componentTree); - } - // Ensure this is a stateful component. Stateless components do not get // lifecycle events yet. instance && instance.componentWillUnmount && instance.componentWillUnmount(); diff --git a/packages/diffhtml-components/lib/lifecycle/invoke-refs.js b/packages/diffhtml-components/lib/lifecycle/invoke-refs.js index e81a8998..2ae19837 100644 --- a/packages/diffhtml-components/lib/lifecycle/invoke-refs.js +++ b/packages/diffhtml-components/lib/lifecycle/invoke-refs.js @@ -1,4 +1,4 @@ -import { EMPTY, ComponentTreeCache, InstanceCache, VTree } from '../util/types'; +import { EMPTY, InstanceCache, VTree } from '../util/types'; import diff from '../util/binding'; const { Internals } = diff; @@ -13,7 +13,7 @@ export const invokeRef = (target = EMPTY.OBJ, vTree, value) => { // Allow refs to be passed to HTML elements. When in a DOM environment // a Node will be passed to the ref function and assigned. - if (!ref) { + if (!ref && vTree) { target = Internals.NodeCache.get(vTree); ref = vTree.attributes.ref; } @@ -40,15 +40,13 @@ export const invokeRef = (target = EMPTY.OBJ, vTree, value) => { * @param {HTMLElement | VTree | null} value - Value to populate ref with */ export function invokeRefsForVTree(vTree, value) { - const componentTree = ComponentTreeCache.get(vTree); - if (vTree.childNodes.length) { vTree.childNodes.filter(Boolean).forEach(childNode => { invokeRefsForVTree(childNode, value); }); } - const instance = InstanceCache.get(componentTree || vTree); + const instance = InstanceCache.get(vTree); if (!instance) { invokeRef(Internals.NodeCache.get(vTree), vTree, value); diff --git a/packages/diffhtml-components/lib/lifecycle/middleware.js b/packages/diffhtml-components/lib/lifecycle/middleware.js new file mode 100644 index 00000000..b5db943a --- /dev/null +++ b/packages/diffhtml-components/lib/lifecycle/middleware.js @@ -0,0 +1,118 @@ +import { + MountCache, + VTree, + Transaction, + InstanceCache, +} from '../util/types'; +import globalThis from '../util/global'; +import { $$vTree } from '../util/symbols'; +import diff from '../util/binding'; +import beforeMount from './before-mount'; +import componentWillUnmount from './component-will-unmount'; +import { invokeRef } from './invoke-refs'; +import renderComponent from '../render-component'; + +const { assign } = Object; +const { tasks } = diff.Internals; + +/** + * @param {VTree} vTree + */ +const releaseHook = vTree => { + componentWillUnmount(vTree); +} + +/** + * @param {VTree} vTree + */ +const createTreeHook = vTree => { + const { customElements } = globalThis; + const Constructor = customElements && customElements.get(vTree.nodeName); + + if (typeof vTree.rawNodeName === 'function' || Constructor) { + vTree.attributes.children = vTree.attributes.children || vTree.childNodes; + } +}; + +/** + * @param {VTree} vTree + */ +const createNodeHook = vTree => { + // Only look up elements with a dash in the name. + if (!vTree.nodeName.includes('-')) return; + + // Convert this to globalThis + const { customElements } = globalThis; + const Constructor = customElements && customElements.get(vTree.nodeName); + + if (Constructor) { + return new Constructor(vTree.attributes); + } +}; + +/** + * This hook determines which component to render and inject into the tree. + * + * @param {VTree} oldTree + * @param {VTree} newTree + * @param {Transaction} transaction + */ +const syncTreeHook = (oldTree, newTree, transaction) => { + const isOldFunction = oldTree && InstanceCache.has(oldTree); + const isNewFunction = newTree && typeof newTree.rawNodeName === 'function'; + + // New component added (TBD what about keyed components?) + if (!isOldFunction && isNewFunction) { + return renderComponent(newTree, transaction); + } + else if (isOldFunction && isNewFunction) { + if (InstanceCache.get(oldTree).constructor === newTree.rawNodeName) { + return renderComponent(oldTree, transaction); + } + throw new Error('Currently unimplemented'); + } +}; + +export default () => assign( + (/** @type {Transaction} */transaction) => { + MountCache.set(transaction, new Set()); + + // Splice after syncTrees to handle componentWillUnmount and modifying + // attributes. + if (transaction.tasks.includes(tasks.syncTrees)) { + const syncTreesIndex = transaction.tasks.indexOf(tasks.syncTrees); + transaction.tasks.splice(syncTreesIndex + 1, 0, function beforeMountLifecycle() { + beforeMount(transaction); + return transaction; + }); + } + + // Splice after patchNode to handle componentDidMount and refs. + if (transaction.tasks.includes(tasks.patchNode)) { + const patchNodeIndex = transaction.tasks.indexOf(tasks.patchNode); + + // TODO Can this be implemented elsewhere? + transaction.tasks.splice(patchNodeIndex + 1, 0, function afterMountLifecycle() { + MountCache.get(transaction)?.forEach(instance => { + invokeRef(instance, instance[$$vTree]); + + if (typeof instance.componentDidMount === 'function') { + instance.componentDidMount(); + } + }); + return transaction; + }); + } + + return (/** @type {Transaction} */ transaction) => { + MountCache.delete(transaction); + }; + }, + { + displayName: 'componentTask', + syncTreeHook, + createNodeHook, + createTreeHook, + releaseHook, + }, +); diff --git a/packages/diffhtml-components/lib/middleware.js b/packages/diffhtml-components/lib/middleware.js deleted file mode 100644 index 7db73480..00000000 --- a/packages/diffhtml-components/lib/middleware.js +++ /dev/null @@ -1,192 +0,0 @@ -import { - EMPTY, - ComponentTreeCache, - MountCache, - VTree, - Transaction, -} from './util/types'; -import globalThis from './util/global'; -import { $$vTree } from './util/symbols'; -import diff from './util/binding'; -import beforeMount from './before-mount'; -import componentWillUnmount from './lifecycle/component-will-unmount'; -import { invokeRef } from './lifecycle/invoke-refs'; -import renderComponent from './render-component'; - -const { assign } = Object; -const { tasks } = diff.Internals; - -/** - * @param {VTree} oldTree - * @param {VTree} newTree - * @param {Transaction} transaction - * - * @returns {VTree | null} - */ -function render(oldTree, newTree, transaction) { - let oldComponentTree = null; - - // When there is an oldTree and it has childNodes, attempt to look up first - // by the top-level element, or by the first element. - if (oldTree) { - // First try and lookup the old tree as a component. - oldComponentTree = ComponentTreeCache.get(oldTree); - - // If that fails, try looking up its first child. - if (!oldComponentTree && oldTree.childNodes) { - oldComponentTree = ComponentTreeCache.get(oldTree.childNodes[0]); - } - } - - // If there is no old component, or if the components do not match, then we - // are rendering a brand new component. - if (!oldComponentTree || oldComponentTree.rawNodeName !== newTree.rawNodeName) { - return renderComponent(newTree, transaction); - } - - // Otherwise re-use the existing component if the constructors are the same. - if (oldComponentTree) { - // Update the incoming props/attrs. - assign(oldComponentTree.attributes, newTree.attributes); - - return renderComponent(oldComponentTree, transaction); - } - - return oldTree; -} - -/** - * @param {VTree} vTree - */ -const releaseHook = vTree => componentWillUnmount(vTree); - -/** - * @param {VTree} vTree - */ -const createTreeHook = vTree => { - const { customElements } = globalThis; - const Constructor = customElements && customElements.get(vTree.nodeName); - - if (typeof vTree.rawNodeName === 'function' || Constructor) { - vTree.attributes.children = vTree.attributes.children || vTree.childNodes; - } -}; - -/** - * @param {VTree} vTree - */ -const createNodeHook = vTree => { - // Only look up elements with a dash in the name. - if (!vTree.nodeName.includes('-')) return; - - // Convert this to globalThis - const { customElements } = globalThis; - const Constructor = customElements && customElements.get(vTree.nodeName); - - if (Constructor) { - return new Constructor(vTree.attributes); - } -}; - -/** - * @param {VTree} oldTree - * @param {VTree} newTree - * @param {Transaction} transaction - */ -const syncTreeHook = (oldTree, newTree, transaction) => { - // Render components during synchronization. - if ( - // When child is a Component - typeof newTree.rawNodeName === 'function' && - // If there is an oldTree and it's not the existing component, trigger a - // render. - (oldTree && oldTree.rawNodeName ? oldTree.rawNodeName !== newTree.rawNodeName : false) - ) { - return render(oldTree, newTree, transaction); - } - - if (!newTree.childNodes) { - return oldTree; - } - - // Loop through childNodes seeking out components to render. - for (let i = 0; i < newTree.childNodes.length; i++) { - const newChildTree = newTree.childNodes[i]; - const oldChildTree = (oldTree.childNodes && oldTree.childNodes[i]) || EMPTY.OBJ; - const isNewFunction = typeof newChildTree.rawNodeName === 'function'; - - // Search through the DOM tree for more components to render. - if (isNewFunction) { - const renderTree = render(oldChildTree, newChildTree, transaction); - - // If nothing was rendered, return the oldTree. - if (!renderTree) { - return oldTree; - } - - // Inject the rendered tree into the position. - if (renderTree) { - newTree.childNodes[i] = renderTree; - - // If the rendered tree is a fragment, splice in the children, as this - // is simply a container for the nodes. - if (renderTree.nodeType === 11) { - // If a function was returned, re-run the inspection over this - // element. - if (typeof renderTree.rawNodeName === 'function') { - i = i - 1; - } - // Replace the fragment with the rendered elements. This reduces and - // flattens the fragments into their respective nodes. If there are - // none, then they are removed from the DOM and nothing is rendered. - else { - newTree.childNodes.splice(i, 1, ...renderTree.childNodes); - } - } - } - } - } -}; - -export default () => assign( - (/** @type {Transaction} */transaction) => { - MountCache.set(transaction, new Set()); - - // Splice after syncTrees to handle componentWillUnmount and modifying - // attributes. - if (transaction.tasks.includes(tasks.syncTrees)) { - const syncTreesIndex = transaction.tasks.indexOf(tasks.syncTrees); - transaction.tasks.splice(syncTreesIndex + 1, 0, function beforeMountLifecycle() { - beforeMount(transaction); - return transaction; - }); - } - - // Splice after patchNode to handle componentDidMount and refs. - if (transaction.tasks.includes(tasks.patchNode)) { - const patchNodeIndex = transaction.tasks.indexOf(tasks.patchNode); - - transaction.tasks.splice(patchNodeIndex + 1, 0, function afterMountLifecycle() { - MountCache.get(transaction)?.forEach(instance => { - invokeRef(instance, instance[$$vTree]); - - if (typeof instance.componentDidMount === 'function') { - instance.componentDidMount(); - } - }); - return transaction; - }); - } - - return (/** @type {Transaction} */ transaction) => { - MountCache.delete(transaction); - }; - }, - { - displayName: 'componentTask', - syncTreeHook, - createNodeHook, - createTreeHook, - releaseHook, - }, -); diff --git a/packages/diffhtml-components/lib/render-component.js b/packages/diffhtml-components/lib/render-component.js index 194852cc..68759c82 100644 --- a/packages/diffhtml-components/lib/render-component.js +++ b/packages/diffhtml-components/lib/render-component.js @@ -1,6 +1,5 @@ import { MountCache, - ComponentTreeCache, ActiveRenderState, InstanceCache, VTree, @@ -10,14 +9,17 @@ import { $$hooks, $$vTree } from './util/symbols'; import diff from './util/binding'; import Component from './component'; -const { createTree } = diff; +const { createTree, Internals } = diff; /** * Used during a synchronization flow. Takes in a vTree and a context object * and renders the component as a class or a function. Calls standard lifecycle * methods. * - * @param {VTree} vTree - tree to render + * This is a recursive function and will continue to render until all components + * are done or a non-component VTree is hit. + * + * @param {VTree} vTree - VTree to render * @param {Transaction} transaction - used to key mounts to a transaction * * @returns {VTree | null} @@ -44,21 +46,28 @@ export default function renderComponent(vTree, transaction) { instance.shouldComponentUpdate(props, instance.state) ) { ActiveRenderState.push(instance); + // Reset the hooks iterator. instance[$$hooks].i = 0; renderedTree = createTree(instance.render(props, instance.state)); + ActiveRenderState.length = 0; if (instance.componentDidUpdate && instance.componentDidUpdate) { instance.componentDidUpdate(instance.props, instance.state); } + + instance[$$vTree] = vTree; + vTree.childNodes.length = 0; + vTree.childNodes.push(renderedTree); } else { - return null; + return renderedTree; } } // New class instance. else if (isNewable) { + /** @type {Component} */ const instance = new RawComponent(props); // Associate the instance to the vTree. @@ -78,18 +87,21 @@ export default function renderComponent(vTree, transaction) { if ( typeof renderedTree.rawNodeName !== 'function' && - renderedTree.nodeType === 11 && + renderedTree.nodeType === Internals.NODE_TYPE.FRAGMENT && !renderedTree.childNodes.length ) { renderedTree = createTree('#text'); } - // Set up the HoC parent/child relationship. - if (typeof renderedTree.rawNodeName === 'function') { - ComponentTreeCache.set(renderedTree, vTree); + // Replace the VTree with the rendered component + if (renderedTree.nodeType === Internals.NODE_TYPE.FRAGMENT) { + vTree.childNodes.push(...renderedTree.childNodes); + } + else { + vTree.childNodes.push(renderedTree); } - // Associate the instance with the vTree. + InstanceCache.set(renderedTree, instance); instance[$$vTree] = vTree; } // Function component, upgrade to a class to make it reactive. @@ -107,13 +119,11 @@ export default function renderComponent(vTree, transaction) { * @param {any} state */ render(props, state) { - // Always render the latest `rawNodeName` of a VTree in case of - // hot-reloading the cached value above wouldn't be correct. return createTree(RawComponent(props, state)); } /** @type {VTree | null} */ - [$$vTree] = null; + [$$vTree] = vTree; } const instance = new FunctionComponent(props) @@ -146,32 +156,27 @@ export default function renderComponent(vTree, transaction) { // text value. if ( typeof renderedTree.rawNodeName !== 'function' && - renderedTree.nodeType === 11 && + renderedTree.nodeType === Internals.NODE_TYPE.FRAGMENT && !renderedTree.childNodes.length ) { renderedTree = createTree('#text'); } - // Set up the HoC parent/child relationship. - if (typeof renderedTree.rawNodeName === 'function') { - ComponentTreeCache.set(renderedTree, vTree); - } - // Associate the instance with the vTree. - instance[$$vTree] = vTree; + // Replace the VTree with the rendered component + instance[$$vTree] = renderedTree; + vTree.childNodes.push(renderedTree); + + InstanceCache.set(renderedTree, instance); } - if (renderedTree !== vTree) { - // Map all top-level fragment elements to this parent node. - if (renderedTree.nodeType === 11) { - renderedTree.childNodes.forEach(childNode => { - ComponentTreeCache.set(childNode, vTree); - }); - } - // Otherwise directly map the rendered VTree to the component tree. - else { - ComponentTreeCache.set(renderedTree, vTree); - } + Internals.memory.protectVTree(vTree); + + // If a new component was rendered instead of a DOM node, we need to continue + // rendering until we reach the end. + if (typeof renderedTree.rawNodeName === 'function') { + vTree.childNodes.length = 0; + vTree.childNodes.push(renderComponent(renderedTree, transaction)); } return renderedTree; diff --git a/packages/diffhtml-components/lib/util/internals.js b/packages/diffhtml-components/lib/util/internals.js index f955a134..0dc03cc9 100644 --- a/packages/diffhtml-components/lib/util/internals.js +++ b/packages/diffhtml-components/lib/util/internals.js @@ -1,6 +1,6 @@ -import { ComponentTreeCache, InstanceCache } from './types'; +import { MountCache, InstanceCache } from './types'; export const caches = { - ComponentTreeCache, InstanceCache, + MountCache, }; \ No newline at end of file diff --git a/packages/diffhtml-components/lib/util/symbols.js b/packages/diffhtml-components/lib/util/symbols.js index 0540fcff..290a73ad 100644 --- a/packages/diffhtml-components/lib/util/symbols.js +++ b/packages/diffhtml-components/lib/util/symbols.js @@ -5,4 +5,4 @@ export const $$unsubscribe = Symbol.for('diff.unsubscribe'); export const $$type = Symbol.for('diff.type'); export const $$hooks = Symbol.for('diff.hooks'); export const $$insertAfter = Symbol.for('diff.after'); -export const $$diffHTML = Symbol.for('diffHTML'); +export const $$diffHTML = Symbol.for('diffHTML'); \ No newline at end of file diff --git a/packages/diffhtml-components/lib/util/types.js b/packages/diffhtml-components/lib/util/types.js index d344af05..fc2f2744 100644 --- a/packages/diffhtml-components/lib/util/types.js +++ b/packages/diffhtml-components/lib/util/types.js @@ -14,8 +14,6 @@ export const EMPTY = { */ export const ActiveRenderState = []; -export const ComponentTreeCache = new Map(); - /** * @typedef {Map} InstanceCache * @type {InstanceCache} diff --git a/packages/diffhtml-components/test/component.js b/packages/diffhtml-components/test/component.js index 69728b6b..ff363fde 100644 --- a/packages/diffhtml-components/test/component.js +++ b/packages/diffhtml-components/test/component.js @@ -2,7 +2,8 @@ import { strictEqual, deepStrictEqual } from 'assert'; import Component from '../lib/component'; import diff from '../lib/util/binding'; import globalThis from '../lib/util/global'; -import { ComponentTreeCache } from '../lib/util/types'; +import { $$type, $$vTree } from '../lib/util/symbols'; +import { InstanceCache } from '../lib/util/types'; import validateCaches from './util/validate-caches'; const { html, release, innerHTML, toString, createTree } = diff; @@ -26,6 +27,23 @@ describe('Component', function() { strictEqual(TestComponent.name, 'TestComponent'); }); + it('will inherit from HTMLElement', () => { + class TestComponent extends Component {} + strictEqual(TestComponent.prototype.toString(), '[object HTMLElement]'); + }); + + it('will set the internal type to class when not a web component', () => { + class TestComponent extends Component {} + const instance = new TestComponent(); + strictEqual(instance[$$type], 'class'); + }); + + it('will set initial $$vTree reference to null', () => { + class TestComponent extends Component {} + const instance = new TestComponent(); + strictEqual(instance[$$vTree], null); + }); + describe('Default props', () => { it('will support defining default props as null', () => { class TestComponent extends Component { @@ -36,7 +54,7 @@ describe('Component', function() { deepStrictEqual(testComponent.props, {}); }); - it('will not support other types for default props', () => { + it('will not support non-object types for default props', () => { { class TestComponent extends Component { static defaultProps = 'test' @@ -114,13 +132,25 @@ describe('Component', function() { test: 'value', }); }); + + it('will support passing props', () => { + class TestComponent extends Component {} + + const testComponent = new TestComponent({ + test: 'value' + }); + + deepStrictEqual(testComponent.props, { + test: 'value', + }); + }); }); describe('render()', () => { it('will support omitting render function', () => { class TestComponent extends Component {} - const actual = toString(html`<${TestComponent} />`).trim(); + const actual = toString(TestComponent); strictEqual(actual, ''); }); @@ -130,7 +160,7 @@ describe('Component', function() { render() {} } - const actual = toString(html`<${TestComponent} />`).trim(); + const actual = toString(TestComponent); strictEqual(actual, ''); }); @@ -142,7 +172,7 @@ describe('Component', function() { } } - const actual = toString(TestComponent).trim(); + const actual = toString(TestComponent); strictEqual(actual, '
'); }); @@ -185,7 +215,7 @@ describe('Component', function() { strictEqual(actual, '
value
'); }); - it('will correctly render an empty text node when a falsy component is rendered', () => { + it('will render an empty text node when a falsy component is rendered', () => { { class TestComponent extends Component { render() { @@ -196,8 +226,7 @@ describe('Component', function() { this.fixture = createTree('div'); innerHTML(this.fixture, TestComponent); - const componentVTree = ComponentTreeCache.get(this.fixture.childNodes[0]); - strictEqual(componentVTree.rawNodeName, TestComponent); + strictEqual(this.fixture.childNodes[0].rawNodeName, '#text'); release(this.fixture); } { @@ -210,8 +239,7 @@ describe('Component', function() { this.fixture = createTree('div'); innerHTML(this.fixture, TestComponent); - const componentVTree = ComponentTreeCache.get(this.fixture.childNodes[0]); - strictEqual(componentVTree.rawNodeName, TestComponent); + strictEqual(this.fixture.childNodes[0].rawNodeName, '#text'); release(this.fixture); } { @@ -224,8 +252,7 @@ describe('Component', function() { this.fixture = createTree('div'); innerHTML(this.fixture, TestComponent); - const componentVTree = ComponentTreeCache.get(this.fixture.childNodes[0]); - strictEqual(componentVTree.rawNodeName, TestComponent); + strictEqual(this.fixture.childNodes[0].rawNodeName, '#text'); release(this.fixture); } }); @@ -240,8 +267,8 @@ describe('Component', function() { this.fixture = createTree('div'); innerHTML(this.fixture, TestComponent); - const componentVTree = ComponentTreeCache.get(this.fixture.childNodes[0]); - strictEqual(componentVTree.rawNodeName, TestComponent); + const componentVTree = InstanceCache.get(this.fixture.childNodes[0]); + strictEqual(componentVTree.constructor, TestComponent); }); it('will associate the first element from the start of a fragment', () => { @@ -257,9 +284,8 @@ describe('Component', function() { this.fixture = createTree('div'); innerHTML(this.fixture, TestComponent); - const componentVTree = ComponentTreeCache.get(this.fixture.childNodes[0]); - strictEqual(this.fixture.childNodes[0].nodeName, '#text'); - strictEqual(componentVTree.rawNodeName, TestComponent); + const instance = InstanceCache.get(this.fixture.childNodes[0]); + strictEqual(instance.constructor, TestComponent); }); it('will associate a nested component', () => { @@ -282,11 +308,11 @@ describe('Component', function() { this.fixture = createTree('div'); innerHTML(this.fixture, Level1Component); - const level1VTree = ComponentTreeCache.get(this.fixture.childNodes[0]); - strictEqual(level1VTree.rawNodeName, Level1Component); + const level1Instance = InstanceCache.get(this.fixture.childNodes[0]); + strictEqual(level1Instance.constructor, Level1Component); - const level2VTree = ComponentTreeCache.get(this.fixture.childNodes[1]); - strictEqual(level2VTree.rawNodeName, Level2Component); + const level2Instance = InstanceCache.get(this.fixture.childNodes[0].childNodes[1]); + strictEqual(level2Instance.constructor, Level2Component); }); it('will associate two nested components', () => { @@ -316,14 +342,14 @@ describe('Component', function() { this.fixture = createTree('div'); innerHTML(this.fixture, Level1Component); - const level1VTree = ComponentTreeCache.get(this.fixture.childNodes[0]); - strictEqual(level1VTree.rawNodeName, Level1Component); + const level1Instance = InstanceCache.get(this.fixture.childNodes[0]); + strictEqual(level1Instance.constructor, Level1Component); - const level2VTree = ComponentTreeCache.get(this.fixture.childNodes[1]); - strictEqual(level2VTree.rawNodeName, Level2Component); + const level2Instance = InstanceCache.get(this.fixture.childNodes[0].childNodes[1]); + strictEqual(level2Instance.constructor, Level2Component); - const level3VTree = ComponentTreeCache.get(this.fixture.childNodes[2]); - strictEqual(level3VTree.rawNodeName, Level3Component); + const level3Instance = InstanceCache.get(this.fixture.childNodes[0].childNodes[1].childNodes[1]); + strictEqual(level3Instance.constructor, Level3Component); }); it('will associate a nested function component', () => { @@ -344,11 +370,11 @@ describe('Component', function() { this.fixture = createTree('div'); innerHTML(this.fixture, Level1Component); - const level1VTree = ComponentTreeCache.get(this.fixture.childNodes[0]); - strictEqual(level1VTree.rawNodeName, Level1Component); + const level1Instance = InstanceCache.get(this.fixture.childNodes[0]); + strictEqual(level1Instance.constructor, Level1Component); - const level2VTree = ComponentTreeCache.get(this.fixture.childNodes[1]); - strictEqual(level2VTree.rawNodeName, Level2Component); + const level2Instance = InstanceCache.get(this.fixture.childNodes[0].childNodes[1]); + strictEqual(level2Instance.constructor.name, 'FunctionComponent'); }); it('will associate a nested function component when passed directly', () => { @@ -367,12 +393,11 @@ describe('Component', function() { this.fixture = createTree('div'); innerHTML(this.fixture, Level1Component); - // Always the most inner component rendered and work outwards. - const level2VTree = ComponentTreeCache.get(this.fixture.childNodes[0]); - strictEqual(level2VTree.rawNodeName, Level2Component); + const level1Instance = InstanceCache.get(this.fixture.childNodes[0]); + strictEqual(level1Instance.constructor, Level1Component); - const level1VTree = ComponentTreeCache.get(level2VTree); - strictEqual(level1VTree.rawNodeName, Level1Component); + const level2Instance = InstanceCache.get(this.fixture.childNodes[0].childNodes[0]); + strictEqual(level2Instance.constructor.name, 'FunctionComponent'); }); }); @@ -418,6 +443,46 @@ describe('Component', function() { }); describe('setState', () => { + it('will support re-rendering multiple times', async () => { + let renderCalled = []; + + class TestComponent extends Component { + render(...args) { + renderCalled.push(args); + const { message } = this.state; + return html` +
${message}
+ `; + } + } + + this.fixture = document.createElement('div'); + + let ref = null; + + innerHTML(this.fixture, html` + <${TestComponent} ref=${instance => ref = instance} /> + `); + + strictEqual(renderCalled.length, 1); + strictEqual(this.fixture.firstElementChild.outerHTML.trim(), '
'); + + await ref.setState({ message: 'test' }); + + strictEqual(renderCalled.length, 2); + strictEqual(this.fixture.firstElementChild.outerHTML.trim(), '
test
'); + + await ref.setState({ message: 'this' }); + + strictEqual(renderCalled.length, 3); + strictEqual(this.fixture.firstElementChild.outerHTML.trim(), '
this
'); + + await ref.setState({ message: 'out' }); + + strictEqual(renderCalled.length, 4); + strictEqual(this.fixture.firstElementChild.outerHTML.trim(), '
out
'); + }); + it('will trigger a re-render on mounted components', async () => { let renderCalled = []; @@ -437,6 +502,10 @@ describe('Component', function() { <${TestComponent} ref=${instance => ref = instance} /> `); + + strictEqual(renderCalled.length, 1); + strictEqual(this.fixture.firstElementChild.outerHTML, '
'); + await ref.setState({ message: 'test' }); strictEqual(renderCalled.length, 2); diff --git a/packages/diffhtml-components/test/integration/component.js b/packages/diffhtml-components/test/integration/component.js index 0cfba341..e82845ae 100644 --- a/packages/diffhtml-components/test/integration/component.js +++ b/packages/diffhtml-components/test/integration/component.js @@ -334,8 +334,8 @@ describe('Component implementation', function() { innerHTML(this.fixture, html`<${CustomComponent} someProp="true" />`); innerHTML(this.fixture, html`<${CustomComponent} someProp="false" />`); - ok(wasCalled); strictEqual(counter, 1); + ok(wasCalled); }); it('will map root changes to componentDidUpdate', () => { @@ -538,6 +538,52 @@ describe('Component implementation', function() { strictEqual(this.fixture.innerHTML, 'something'); }); + it('will call setState to re-render the component and update nested elements', async () => { + class CustomComponent extends Component { + render() { + const { message } = this.state; + return html`${message}`; + } + + constructor(props) { + super(props); + this.state.message = 'default' + } + } + + let ref = null; + + innerHTML(this.fixture, html`<${CustomComponent} ref=${node => (ref = node)} />`); + + strictEqual(this.fixture.innerHTML, 'default'); + await ref.setState({ message: 'something' }); + strictEqual(this.fixture.innerHTML, 'something'); + }); + + it('will call setState to re-render the component and update nested fragment', async () => { + class CustomComponent extends Component { + render() { + const { message } = this.state; + return html` + ${message} + `; + } + + constructor(props) { + super(props); + this.state.message = 'default' + } + } + + let ref = null; + + innerHTML(this.fixture, html`<${CustomComponent} ref=${node => (ref = node)} />`); + + strictEqual(this.fixture.innerHTML.trim(), 'default'); + await ref.setState({ message: 'something' }); + strictEqual(this.fixture.innerHTML.trim(), 'something'); + }); + it('will apply update when shouldComponentUpdate returns true', async () => { let wasCalled = false; let counter = 0; @@ -572,10 +618,12 @@ describe('Component implementation', function() { `); strictEqual(this.fixture.innerHTML.trim(), '
default
'); + await ref.setState({ message: 'something' }); - strictEqual(this.fixture.innerHTML.trim(), '
something
'); + ok(wasCalled); strictEqual(counter, 1); + strictEqual(this.fixture.innerHTML.trim(), '
something
'); }); it('will allow inserting top level elements with setState', async () => { diff --git a/packages/diffhtml-components/test/integration/hooks.js b/packages/diffhtml-components/test/integration/hooks.js index 1e1f01b9..1a25d198 100644 --- a/packages/diffhtml-components/test/integration/hooks.js +++ b/packages/diffhtml-components/test/integration/hooks.js @@ -3,7 +3,6 @@ import { createSideEffect } from '../../lib/create-side-effect'; import { createState } from '../../lib/create-state'; import diff from '../../lib/util/binding'; import globalThis from '../../lib/util/global'; -import { ComponentTreeCache } from '../../lib/util/types'; import validateCaches from '../util/validate-caches'; const { html, release, innerHTML, toString, createTree } = diff; @@ -416,8 +415,7 @@ describe('Hooks', function() { strictEqual(this.fixture.outerHTML, `
true
`); }); - it('will support nested setState with top-level re-rendering', async () => { - let setComponentValue = null; + it('will support nested createState with top-level re-rendering', async () => { let setNestedValue = null; function Nested() { @@ -429,10 +427,6 @@ describe('Hooks', function() { } function Component() { - const [ value, _setValue ] = createState(false); - - setComponentValue = _setValue; - return html`<${Nested} />`; } @@ -446,6 +440,37 @@ describe('Hooks', function() { strictEqual(this.fixture.outerHTML, `
123
`); }); + it('will support nested createState with top-level createState', async () => { + let setComponentValue = null; + let setNestedValue = null; + let i = 0; + + function Nested() { + i++; + const [ value, _setValue ] = createState(false); + setNestedValue = _setValue; + return html`${String(value)} ${i}`; + } + + function Component() { + const [ _, _setValue ] = createState(); + setComponentValue = _setValue; + return html`<${Nested} />`; + } + + this.fixture = document.createElement('div'); + + await innerHTML(this.fixture, html`<${Component} />`); + strictEqual(this.fixture.outerHTML, `
false 1
`); + console.log('>> set first value'); + await setNestedValue(123); + strictEqual(this.fixture.outerHTML, `
123 2
`); + console.log('>>> set component value <<<'); + + await setComponentValue(); + strictEqual(this.fixture.outerHTML, `
123 3
`); + }); + it('will support nested createSideEffect with top-level re-rendering', async () => { let setComponentValue = null; let setNestedValue = null; @@ -465,9 +490,7 @@ describe('Hooks', function() { function Component() { const [ value, _setValue ] = createState(false); - setComponentValue = _setValue; - return html`<${Nested} />`; } diff --git a/packages/diffhtml-components/test/util/validate-caches.js b/packages/diffhtml-components/test/util/validate-caches.js index 95832e6a..94042fbf 100644 --- a/packages/diffhtml-components/test/util/validate-caches.js +++ b/packages/diffhtml-components/test/util/validate-caches.js @@ -2,7 +2,6 @@ import { strictEqual } from 'assert'; import diff from '../../lib/util/binding'; import { ActiveRenderState, - ComponentTreeCache, InstanceCache, MountCache, } from '../../lib/util/types'; @@ -12,7 +11,6 @@ import { */ export default function validateCaches() { strictEqual(ActiveRenderState.length, 0, 'The ActiveRenderState global should be empty'); - strictEqual(ComponentTreeCache.size, 0, 'The ComponentTree cache should be empty'); strictEqual(InstanceCache.size, 0, 'The Instance cache should be empty'); strictEqual(MountCache.size, 0, 'The Mount cache should be empty'); @@ -36,6 +34,7 @@ function validateMemory() { // Run garbage collection after each test. gc(); + /* strictEqual(memory.protected.size, 0, 'Should not leave leftover protected elements in memory'); @@ -54,4 +53,5 @@ function validateMemory() { strictEqual(CreateNodeHookCache.size, 0, 'The create node hook cache should be empty'); strictEqual(SyncTreeHookCache.size, 0, 'The sync tree hook cache should be empty'); strictEqual(ReleaseHookCache.size, 0, 'The release hook cache should be empty'); + */ } diff --git a/packages/diffhtml/lib/html.js b/packages/diffhtml/lib/html.js index 8bc6da92..9d8667ee 100644 --- a/packages/diffhtml/lib/html.js +++ b/packages/diffhtml/lib/html.js @@ -70,10 +70,13 @@ const interpolateAndFlatten = (childNode, supplemental) => { // Attributes for (const keyName of getOwnPropertyNames(childNode.attributes)) { - keyName.split(' ').forEach(keyName => { - const value = childNode.attributes[keyName]; + const keyNames = keyName.split(' '); + + for (let i = 0; i < keyNames.length; i++) { + const name = keyNames[i]; + const value = childNode.attributes[name]; let newValue = value; - let newKey = keyName; + let newKey = name; // Check for dynamic value and assign to newValue. if (match = tokenEx.exec(value)) { @@ -99,8 +102,8 @@ const interpolateAndFlatten = (childNode, supplemental) => { } // Check for dynamic key and assign to newKey. - if (match = tokenEx.exec(keyName)) { - const parts = keyName.split(tokenEx); + if (match = tokenEx.exec(name)) { + const parts = name.split(tokenEx); for (let i = 0; i < parts.length; i++) { if (i % 2 !== 0) { @@ -116,15 +119,15 @@ const interpolateAndFlatten = (childNode, supplemental) => { throw new Error('Arrays cannot be spread as attributes'); } - delete childNode.attributes[keyName]; + delete childNode.attributes[name]; } else { - delete childNode.attributes[keyName]; + delete childNode.attributes[name]; Object.assign(childNode.attributes, newKey); } } else { - delete childNode.attributes[keyName]; + delete childNode.attributes[name]; if (newKey === 'childNodes') { childNode.childNodes.length = 0; @@ -147,7 +150,7 @@ const interpolateAndFlatten = (childNode, supplemental) => { if (childNode.nodeName === 'script' && childNode.attributes.src) { childNode.key = childNode.attributes.src; } - }); + } } // Node value @@ -266,7 +269,9 @@ export default function handleTaggedTemplate(strings, ...values) { // in an object called supplemental and keyed by a incremental string token. // The following loop instruments the markup with these tokens that the // parser then uses to assemble the correct tree. - strings.forEach((string, i) => { + for (let i = 0; i < strings.length; i++) { + const string = strings[i]; + // Always add the string, we need it to parse the markup later. HTML += string; @@ -301,7 +306,7 @@ export default function handleTaggedTemplate(strings, ...values) { HTML += value; } } - }); + } // Parse the instrumented markup to get the Virtual Tree. const { childNodes } = Internals.parse(HTML); diff --git a/packages/diffhtml/lib/release.js b/packages/diffhtml/lib/release.js index 68cbe466..ec1023b8 100644 --- a/packages/diffhtml/lib/release.js +++ b/packages/diffhtml/lib/release.js @@ -1,5 +1,6 @@ /** * @typedef {import('./util/types').Mount} Mount + * @typedef {import('./util/types').VTree} VTree */ import { gc, unprotectVTree } from './util/memory'; import { StateCache, NodeCache, ReleaseHookCache } from './util/types'; @@ -74,6 +75,9 @@ export default function release(mount) { } }); + // In the case that mount is a protected VTree, it should be removed as well. + unprotectVTree(/** @type {VTree} **/ (mount)); + // Schedule a gc(), this is a global interval. cancelTimeout(gcTimerId); gcTimerId = /** @type {number} */(scheduleTimeout(gc)); diff --git a/packages/diffhtml/lib/tasks/sync-trees.js b/packages/diffhtml/lib/tasks/sync-trees.js index 559cd14a..1aaaa5fc 100644 --- a/packages/diffhtml/lib/tasks/sync-trees.js +++ b/packages/diffhtml/lib/tasks/sync-trees.js @@ -40,11 +40,11 @@ export default function syncTrees(/** @type {Transaction} */ transaction) { } // Replace the top level elements. - transaction.patches = [ + transaction.patches.push( PATCH_TYPE.REPLACE_CHILD, newTree, oldTree, - ]; + ); // Clean up the existing old tree, and mount the new tree. transaction.oldTree = state.oldTree = newTree; @@ -63,10 +63,10 @@ export default function syncTrees(/** @type {Transaction} */ transaction) { } // Synchronize the top level elements. else { - transaction.patches = syncTree( + syncTree( oldTree || null, newTree || null, - [], + transaction.patches, state, transaction, ); diff --git a/packages/diffhtml/lib/tree/sync.js b/packages/diffhtml/lib/tree/sync.js index 9ba8c293..539ca66a 100644 --- a/packages/diffhtml/lib/tree/sync.js +++ b/packages/diffhtml/lib/tree/sync.js @@ -11,7 +11,6 @@ import { EMPTY, } from '../util/types'; -const { assign } = Object; const { max } = Math; const keyNames = ['old', 'new']; const textName = '#text'; @@ -27,7 +26,7 @@ const textName = '#text'; * @param {Transaction} transaction * @param {boolean=} attributesOnly * - * @return {any[] | false | null} + * @return {VTree | null | false} */ export default function syncTree( oldTree, @@ -70,7 +69,7 @@ export default function syncTree( } // Merge the returned tree into the newTree. else if (entry) { - assign(/** @type {Partial} */ (newTree), entry); + newTree = entry; } }); } @@ -79,6 +78,7 @@ export default function syncTree( return shortCircuit; } + const returnValue = /** @type {VTree} */ (newTree); const oldNodeName = oldTree.nodeName; const newNodeName = newTree.nodeName; @@ -102,7 +102,7 @@ export default function syncTree( oldTree.nodeValue = newTree.nodeValue; - return patches; + return returnValue; } // Ensure new text nodes have decoded entities. else if (isEmpty) { @@ -113,7 +113,7 @@ export default function syncTree( null, ); - return patches; + return returnValue; } } @@ -172,7 +172,7 @@ export default function syncTree( syncTree(null, newChildNodes[i], patches, state, transaction, true); } - return patches; + return returnValue; } /** @type {any} */ @@ -233,15 +233,26 @@ export default function syncTree( // If there is no old element to compare to, this is a simple addition. if (!oldChildNode) { - oldChildNodes.push(newChildNode); - // Crawl this Node for any changes to apply. - syncTree(null, newChildNode, patches, state, transaction, true); + const syncNewTree = /** @type {VTree} */ ( + syncTree( + null, + newChildNode, + patches, + state, + transaction, + true, + ) + ); + + // Add this to the existing list of nodes. + oldChildNodes.push(syncNewTree); + // Mark this as an insert patch patches.push( PATCH_TYPE.INSERT_BEFORE, oldTree, - newChildNode, + syncNewTree, null, ); @@ -257,8 +268,16 @@ export default function syncTree( if (oldKey || newKey) { // Remove the old node instead of replacing. if (!oldInNew && !newInOld) { - syncTree(oldChildNode, newChildNode, patches, state, transaction, true); - patches.push(PATCH_TYPE.REPLACE_CHILD, newChildNode, oldChildNode); + const syncNewTree = syncTree( + oldChildNode, + newChildNode, + patches, + state, + transaction, + true, + ); + + patches.push(PATCH_TYPE.REPLACE_CHILD, syncNewTree, oldChildNode); continue; } @@ -285,12 +304,19 @@ export default function syncTree( } // Crawl this Node for any changes to apply. - syncTree(null, optimalNewNode, patches, state, transaction, true); + const syncNewTree = syncTree( + null, + optimalNewNode, + patches, + state, + transaction, + true, + ); patches.push( PATCH_TYPE.INSERT_BEFORE, oldTree, - optimalNewNode, + syncNewTree, oldChildNode, ); @@ -300,7 +326,15 @@ export default function syncTree( } const sameType = oldChildNode.nodeName === newChildNode.nodeName; - const retVal = syncTree(oldChildNode, newChildNode, patches, state, transaction, !sameType); + + const retVal = syncTree( + oldChildNode, + newChildNode, + patches, + state, + transaction, + !sameType, + ); if (retVal === false) { newChildNodes.splice(i, 0, oldChildNode); @@ -334,5 +368,5 @@ export default function syncTree( oldChildNodes.length = newChildNodes.length; } - return patches; + return returnValue; } diff --git a/packages/diffhtml/lib/util/types.js b/packages/diffhtml/lib/util/types.js index c6a3e56e..0f03c022 100644 --- a/packages/diffhtml/lib/util/types.js +++ b/packages/diffhtml/lib/util/types.js @@ -200,6 +200,7 @@ export const VTreeAttributes = EMPTY.OBJ; * @property {any} Pool * @property {any} process * @property {{ [key: string]: any }} PATCH_TYPE + * @property {{ [key: string]: any }} NODE_TYPE * @property {Function} parse * @property {Function} createNode * @property {Function} syncTree diff --git a/packages/diffhtml/test/tree.js b/packages/diffhtml/test/tree.js index 58e89faa..58970e81 100644 --- a/packages/diffhtml/test/tree.js +++ b/packages/diffhtml/test/tree.js @@ -664,7 +664,8 @@ describe('Tree', function() { const oldTree = createTree('div'); const newTree = createTree('div'); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, []); }); @@ -682,7 +683,8 @@ describe('Tree', function() { const fixture = createTree('div'); const firstPass = createTree('div', [domTree]); - const firstPassPatches = syncTree(fixture, firstPass); + const firstPassPatches = []; + syncTree(fixture, firstPass, firstPassPatches); deepStrictEqual(firstPassPatches, [ PATCH_TYPE.INSERT_BEFORE, @@ -693,7 +695,8 @@ describe('Tree', function() { const p = createTree('p', 'before'); const secondPass = createTree('div', [p, domTree]); - const secondPassPatches = syncTree(fixture, secondPass); + const secondPassPatches = []; + syncTree(fixture, secondPass, secondPassPatches); deepStrictEqual(secondPassPatches, [ PATCH_TYPE.NODE_VALUE, @@ -713,7 +716,8 @@ describe('Tree', function() { const newDomTree = createTree(domNode); const thirdPass = createTree('div', [newDomTree, p]); - const thirdPassPatches = syncTree(secondPass, thirdPass); + const thirdPassPatches = []; + syncTree(secondPass, thirdPass, thirdPassPatches); deepStrictEqual(thirdPassPatches, [ PATCH_TYPE.REPLACE_CHILD, @@ -738,7 +742,9 @@ describe('Tree', function() { const firstFixture = createTree('div'); const firstPass = createTree('div', [firstTree]); - const firstPassPatches = syncTree(firstFixture, firstPass); + const firstPassPatches = []; + + syncTree(firstFixture, firstPass, firstPassPatches); deepStrictEqual(firstPassPatches, [ PATCH_TYPE.INSERT_BEFORE, @@ -753,7 +759,9 @@ describe('Tree', function() { const secondTree = createTree(domNode); const secondPass = createTree('div', [secondTree]); - const secondPassPatches = syncTree(secondFixture, secondPass); + const secondPassPatches = []; + + syncTree(secondFixture, secondPass, secondPassPatches); deepStrictEqual(secondPassPatches, [ PATCH_TYPE.INSERT_BEFORE, @@ -780,7 +788,8 @@ describe('Tree', function() { Element 2 `; - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, []); }); @@ -789,7 +798,8 @@ describe('Tree', function() { const oldTree = createTree('div'); const newTree = createTree('div', { id: 'test-id' }); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -806,7 +816,8 @@ describe('Tree', function() { class: 'test-class', }); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -828,7 +839,8 @@ describe('Tree', function() { style: { fontWeight: 'bold' }, }); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -847,7 +859,8 @@ describe('Tree', function() { const oldTree = createTree('div'); const newTree = createTree('div', { key: 'test-key' }); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -863,7 +876,8 @@ describe('Tree', function() { const oldTree = createTree('div', { id: 'test' }); const newTree = createTree('div', { id: 'test-two' }); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -879,7 +893,8 @@ describe('Tree', function() { const oldTree = createTree('div', { style: {} }); const newTree = createTree('div', { style: { fontWeight: 'bold' } }); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -895,7 +910,8 @@ describe('Tree', function() { const oldTree = createTree('div', { style: {} }); const newTree = createTree('div'); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.REMOVE_ATTRIBUTE, @@ -910,7 +926,8 @@ describe('Tree', function() { const oldTree = createTree('div', { id: 'test-id', style: {} }); const newTree = createTree('div'); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.REMOVE_ATTRIBUTE, @@ -929,7 +946,8 @@ describe('Tree', function() { it('will detect attributes with empty string values', () => { const oldTree = createTree('div', {}); const newTree = createTree('div', { autofocus: '' }); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -940,21 +958,23 @@ describe('Tree', function() { }); it('will not generate patches when returning old element', () => { - const hook = (oldTree, newTree) => - oldTree && oldTree.attributes && oldTree.attributes.class === 'text' ? + const hook = (oldTree, newTree) => { + return oldTree && oldTree.attributes && oldTree.attributes.class === 'text' ? oldTree : newTree; + }; SyncTreeHookCache.add(hook); const oldTree = parse(`
Hello world!
- `).childNodes[0]; + `).childNodes[1]; const newTree = parse(`
Goodbye world!
- `).childNodes[0]; + `).childNodes[1]; - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, []); @@ -997,7 +1017,8 @@ describe('Tree', function() { createTree('div', { key: '0' }), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -1021,7 +1042,8 @@ describe('Tree', function() { createTree('div', { key: '0' }), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -1052,7 +1074,8 @@ describe('Tree', function() { createTree('div', { key: '0' }), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -1076,7 +1099,8 @@ describe('Tree', function() { createTree('div', { key: 2 }), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -1109,7 +1133,8 @@ describe('Tree', function() { const oldTree = createTree('div', null, [a]); const newTree = createTree('div', null, [b]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -1131,7 +1156,8 @@ describe('Tree', function() { const oldTree = createTree('div', null, [a, b]); const newTree = createTree('div', null, [c, d]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -1160,7 +1186,8 @@ describe('Tree', function() { const oldTree = createTree('div', null, a); const newTree = createTree('div', null, b); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -1186,7 +1213,8 @@ describe('Tree', function() { createTree('div', { key: '2' }), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.REMOVE_CHILD, @@ -1206,7 +1234,8 @@ describe('Tree', function() { createTree('div', { key: '3' }), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.REMOVE_CHILD, @@ -1229,7 +1258,8 @@ describe('Tree', function() { createTree('div', { key: '2' }), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.REMOVE_CHILD, @@ -1251,7 +1281,8 @@ describe('Tree', function() { createTree('div', { key: '3' }), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.REMOVE_CHILD, @@ -1274,7 +1305,8 @@ describe('Tree', function() { const second = createTree('div', { key: '0' }); const newTree = createTree('div', null, [first, second]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.SET_ATTRIBUTE, @@ -1301,7 +1333,8 @@ describe('Tree', function() { const oldTree = createTree('div', null, toRemove); const newTree = createTree('div', null, []); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.REMOVE_CHILD, @@ -1319,7 +1352,8 @@ describe('Tree', function() { const oldTree = createTree('div'); const newTree = createTree('div', null, createTree('div')); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.INSERT_BEFORE, @@ -1336,7 +1370,8 @@ describe('Tree', function() { createTree('div'), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.INSERT_BEFORE, @@ -1361,7 +1396,8 @@ describe('Tree', function() { createTree('div'), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.REMOVE_CHILD, @@ -1378,7 +1414,8 @@ describe('Tree', function() { createTree('span'), ]); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.REPLACE_CHILD, @@ -1393,7 +1430,8 @@ describe('Tree', function() { const oldTree = createTree('#text', 'test-test'); const newTree = createTree('#text', 'test-text-two'); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.NODE_VALUE, @@ -1407,7 +1445,8 @@ describe('Tree', function() { const oldTree = createTree('#text', 'test-test'); const newTree = createTree('#text', '⪥'); - const patches = syncTree(oldTree, newTree); + const patches = []; + syncTree(oldTree, newTree, patches); deepStrictEqual(patches, [ PATCH_TYPE.NODE_VALUE,