From 3e68bbda4f0202067e7a8395b0dc379432e70fcc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82a=C5=BCej=20Che=C5=82kowski?= Date: Fri, 17 Jul 2026 15:24:56 +0200 Subject: [PATCH 1/2] fix: optimize the renderer by using child map in virtual dom --- src/components/renderer/Kopytko.brs | 9 +- src/components/renderer/KopytkoDOM.brs | 50 +++++-- .../renderer/KopytkoDiffUtility.brs | 130 +++++++++++++----- 3 files changed, 139 insertions(+), 50 deletions(-) diff --git a/src/components/renderer/Kopytko.brs b/src/components/renderer/Kopytko.brs index 6201c7d..2a6b134 100644 --- a/src/components/renderer/Kopytko.brs +++ b/src/components/renderer/Kopytko.brs @@ -96,11 +96,12 @@ sub updateProps(props = {} as Object) end sub sub _mountComponent() - m._virtualDOM = render() - - _methodCall(m._kopytkoDOM.renderElement, "renderElement", [m._virtualDOM, m.top], m._kopytkoDOM) + rawVirtualDOM = render() + _methodCall(m._kopytkoDOM.renderElement, "renderElement", [rawVirtualDOM, m.top], m._kopytkoDOM) _methodCall(m._kopytkoUpdater.setComponentMounted, "setComponentMounted", [m.state], m._kopytkoUpdater) _methodCall(componentDidMount, "componentDidMount") + + m._virtualDOM = m._kopytkoDiffUtility._normaliseVNode(rawVirtualDOM) end sub sub _onStateUpdated() @@ -110,7 +111,7 @@ end sub sub _updateDOM() newVirtualDOM = render() diffResult = m._kopytkoDiffUtility.diffDOM(m._virtualDOM, newVirtualDOM) - m._virtualDOM = newVirtualDOM + m._virtualDOM = diffResult.normalisedVirtualDOM wasInFocusChain = m.top.isInFocusChain() m._kopytkoDOM.updateDOM(diffResult) diff --git a/src/components/renderer/KopytkoDOM.brs b/src/components/renderer/KopytkoDOM.brs index f810720..cb576b3 100644 --- a/src/components/renderer/KopytkoDOM.brs +++ b/src/components/renderer/KopytkoDOM.brs @@ -9,7 +9,15 @@ function KopytkoDOM() as Object ' @param {Object} vNode - The virtual node ' @param {Object} parentElement - The parent element where the element will be rendered prototype.renderElement = sub (vNode as Object, parentElement = Invalid as Object) - if (Type(vNode) = "roArray") + vNodeType = Type(vNode) + + if (vNodeType = "roArray") + m._renderElementChildren(vNode, parentElement) + + return + end if + + if (vNodeType = "roAssociativeArray" AND vNode.name = Invalid) m._renderElementChildren(vNode, parentElement) return @@ -150,15 +158,31 @@ function KopytkoDOM() as Object ' @private prototype._renderElementChildren = sub (children as Object, parentElement as Object) - if (children = Invalid) - return - end if + if (children = Invalid) then return + + if (Type(children) = "roAssociativeArray") + ' Sort by order field before rendering — guarantees correct insertChild sequence + ' since roAssociativeArray does not preserve iteration order + orderedChildren = [] + + for each childId in children + child = children[childId] + if (child <> Invalid) then orderedChildren.push(child) + end for + + orderedChildren.sortBy("order") - for each vChildNode in children - if (vChildNode <> Invalid) + for each vChildNode in orderedChildren m.renderElement(vChildNode, parentElement) - end if - end for + end for + else + ' Fallback — roArray (e.g. during first render before normalisation propagates) + for each vChildNode in children + if (vChildNode <> Invalid) + m.renderElement(vChildNode, parentElement) + end if + end for + end if end sub ' @private @@ -168,8 +192,14 @@ function KopytkoDOM() as Object ' @private prototype._isVNodeValid = function (vNode as Object) as Boolean - if (Type(vNode) = "roAssociativeArray" AND vNode.count() = 0) - return false ' Let render empty component without printing any warning + if (Type(vNode) <> "roAssociativeArray") + print "vNode must be an AssociativeArray, got: " + Type(vNode) + + return false + end if + + if (vNode.count() = 0) + return false end if if (vNode.name = Invalid) diff --git a/src/components/renderer/KopytkoDiffUtility.brs b/src/components/renderer/KopytkoDiffUtility.brs index fb0a919..29044f8 100644 --- a/src/components/renderer/KopytkoDiffUtility.brs +++ b/src/components/renderer/KopytkoDiffUtility.brs @@ -19,18 +19,32 @@ function KopytkoDiffUtility() as Object elementsToRemove: [], } - if (Type(currentVirtualDOM) = "roArray" AND (newVirtualDOM = Invalid OR Type(newVirtualDOM) = "roArray")) - m._diffElementChildren(currentVirtualDOM, newVirtualDOM) + normalisedNew = m._normaliseVNode(newVirtualDOM) + currentIsCollection = m._isChildrenCollection(currentVirtualDOM) + newIsCollection = m._isChildrenCollection(normalisedNew) + + if (currentIsCollection AND newIsCollection) + m._diffElementChildren(currentVirtualDOM, normalisedNew) else - m._diffElement(currentVirtualDOM, newVirtualDOM) + m._diffElement(currentVirtualDOM, normalisedNew) end if diffResult = m._diffResult + diffResult.normalisedVirtualDOM = normalisedNew m.delete("_diffResult") return diffResult end function + ' @private + prototype._isChildrenCollection = function (vNode as Object) as Boolean + if (vNode = Invalid) then return true + if (Type(vNode) = "roArray") then return true + if (Type(vNode) <> "roAssociativeArray") then return false + + return (vNode.name = Invalid) + end function + ' @private prototype._diffElement = sub (currentElement as Object, newElement as Object) if (currentElement = Invalid AND newElement = Invalid) @@ -49,14 +63,13 @@ function KopytkoDiffUtility() as Object return end if - ' kopytko-disable-next-line identifier/undefined-variable - if (Type(currentVirtualDOM) <> Type(newVirtualDOM)) - print "DOM type should not be changed" + currentId = Invalid + if (currentElement.props <> Invalid) then currentId = currentElement.props.id - return - end if + newId = Invalid + if (newElement.props <> Invalid) then newId = newElement.props.id - if (currentElement.name <> newElement.name OR currentElement.props.id <> newElement.props.id) + if (currentElement.name <> newElement.name OR currentId <> newId) m._diffResult.elementsToRender.push(newElement) m._markElementToBeRemoved(currentElement) @@ -67,8 +80,8 @@ function KopytkoDiffUtility() as Object newElement.dynamicProps = {} end if - m._diffElementProps(currentElement.props.id, currentElement.dynamicProps, newElement.dynamicProps) - m._diffElementChildren(currentElement.children, newElement.children, newElement.props.id) + m._diffElementProps(currentId, currentElement.dynamicProps, newElement.dynamicProps) + m._diffElementChildren(currentElement.children, newElement.children, newId) end sub ' @private @@ -85,51 +98,96 @@ function KopytkoDiffUtility() as Object end for end sub - ' @todo Support elements reordering ' @private prototype._diffElementChildren = sub (currentChildren as Object, newChildren as Object, parentElementId = Invalid as Dynamic) - if (newChildren = Invalid) then newChildren = [] + if (newChildren = Invalid) then newChildren = {} - currentChildrenMapped = {} - if (currentChildren <> Invalid) - for each currentChild in currentChildren - if (currentChild <> Invalid) - currentChildrenMapped[currentChild.props.id] = currentChild - end if - end for + if (Type(currentChildren) = "roArray") + currentChildren = m._normaliseChildArray(currentChildren) end if - nonInvalidNewChildIndex = 0 - for each newChild in newChildren - if (newChild <> Invalid) - newChild.index = nonInvalidNewChildIndex - nonInvalidNewChildIndex++ + for each childId in newChildren + newChild = newChildren[childId] + if (newChild <> Invalid AND Type(newChild) = "roAssociativeArray") + newChild.index = newChild.order newChild.parentId = parentElementId - m._diffElement(currentChildrenMapped[newChild.props.id], newChild) - currentChildrenMapped.delete(newChild.props.id) + currentChild = Invalid + if (currentChildren <> Invalid) then currentChild = currentChildren[childId] + m._diffElement(currentChild, newChild) end if end for - for each currentChildIdToRemove in currentChildrenMapped - m._markElementToBeRemoved(currentChildrenMapped[currentChildIdToRemove]) - end for + if (currentChildren <> Invalid) + for each childId in currentChildren + currentChild = currentChildren[childId] + if (newChildren[childId] = Invalid AND currentChild <> Invalid AND Type(currentChild) = "roAssociativeArray") + m._markElementToBeRemoved(currentChild) + end if + end for + end if end sub ' @private prototype._markElementToBeRemoved = sub (element as Object) + if (element = Invalid OR Type(element) <> "roAssociativeArray") then return + if (element.props = Invalid OR element.props.id = Invalid) then return + m._diffResult.elementsToRemove.push(element.props.id) - if (element.children = Invalid OR element.children.count() = 0) - return + if (element.children = Invalid OR element.children.count() = 0) then return + + if (Type(element.children) = "roAssociativeArray") + for each childId in element.children + m._markElementToBeRemoved(element.children[childId]) + end for + else + for each child in element.children + if (child <> Invalid) + m._markElementToBeRemoved(child) + end if + end for + end if + end sub + + ' @private + prototype._normaliseVNode = function (vNode as Object) as Object + if (vNode = Invalid) then return Invalid + + ' render() returned a top-level array of vNodes + if (Type(vNode) = "roArray") + return m._normaliseChildArray(vNode) + end if + + if (Type(vNode) <> "roAssociativeArray") then return vNode + + ' Single vNode — normalise its children if still an array + if (vNode.children <> Invalid AND Type(vNode.children) = "roArray") + vNode.children = m._normaliseChildArray(vNode.children) end if - for each child in element.children - if (child <> Invalid) - m._markElementToBeRemoved(child) + return vNode + end function + + ' @private + prototype._normaliseChildArray = function (children as Object) as Object + result = {} + order = 0 + for each child in children + if (child <> Invalid AND Type(child) = "roAssociativeArray") + if (child.props <> Invalid AND child.props.id <> Invalid) + child.order = order + if (child.children <> Invalid AND Type(child.children) = "roArray") + child.children = m._normaliseChildArray(child.children) + end if + result[child.props.id] = child + end if end if + order++ end for - end sub + + return result + end function return prototype end function From 7e5abc66323efe0f172850961b38f31a3acf7096 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?B=C5=82a=C5=BCej=20Che=C5=82kowski?= Date: Fri, 17 Jul 2026 16:00:22 +0200 Subject: [PATCH 2/2] fix: harden virtual DOM child map normalisation and diffing - store the normalised virtual DOM before componentDidMount so a synchronous forceUpdate() from there no longer re-renders the whole tree - mark children maps with an explicit __childrenMap/byId structure instead of detecting them by a missing name field, so child ids like 'name' or 'count' cannot break collection detection or shadow AA methods - assign dense order values (skipping Invalid and dropped children) so insertChild indexes are not shifted by conditionally hidden children - recurse into children when marking id-less elements for removal, so switching the render output shape no longer leaks the previous subtree - warn about children with missing or duplicated props.id instead of silently dropping them; duplicated ids keep the position of the first occurrence - expose normaliseVNode as a public KopytkoDiffUtility method --- docs/renderer.md | 3 +- src/components/renderer/Kopytko.brs | 9 +- src/components/renderer/KopytkoDOM.brs | 22 +- .../renderer/KopytkoDiffUtility.brs | 162 ++++++----- .../KopytkoDOM_renderElement.test.brs | 65 +++++ .../KopytkoDiffUtility_Main.test.brs | 14 +- .../KopytkoDiffUtility_Normalisation.test.brs | 264 ++++++++++++++++++ 7 files changed, 457 insertions(+), 82 deletions(-) create mode 100644 src/components/renderer/_tests/kopytkoDiffUtility/KopytkoDiffUtility_Normalisation.test.brs diff --git a/docs/renderer.md b/docs/renderer.md index 9c53c23..d2dcd31 100644 --- a/docs/renderer.md +++ b/docs/renderer.md @@ -49,7 +49,8 @@ It must return 1 of 3 values: The Element interface: - `name` - name of the component to be rendered (required), -- `props` - defines static fields and custom props of the element; any change of these values won't update the element +- `props` - defines static fields and custom props of the element; any change of these values won't update the element; + `props.id` is required and must be unique between siblings - children without it (or repeating a sibling's id) are skipped with a warning printed in the debug console, - `dynamicProps` - defines fields and custom props with dynamic values, their every change will trigger `componentDidUpdate` lifecycle method, - `events` - defines callbacks to the component field changes - `children` - array of objects implementing this Element interface. diff --git a/src/components/renderer/Kopytko.brs b/src/components/renderer/Kopytko.brs index 2a6b134..e101099 100644 --- a/src/components/renderer/Kopytko.brs +++ b/src/components/renderer/Kopytko.brs @@ -96,12 +96,13 @@ sub updateProps(props = {} as Object) end sub sub _mountComponent() - rawVirtualDOM = render() - _methodCall(m._kopytkoDOM.renderElement, "renderElement", [rawVirtualDOM, m.top], m._kopytkoDOM) + ' The virtual DOM has to be normalised and stored before componentDidMount is called - a synchronous + ' forceUpdate() triggered from there diffs against m._virtualDOM and would re-render the whole tree otherwise + m._virtualDOM = m._kopytkoDiffUtility.normaliseVNode(render()) + + _methodCall(m._kopytkoDOM.renderElement, "renderElement", [m._virtualDOM, m.top], m._kopytkoDOM) _methodCall(m._kopytkoUpdater.setComponentMounted, "setComponentMounted", [m.state], m._kopytkoUpdater) _methodCall(componentDidMount, "componentDidMount") - - m._virtualDOM = m._kopytkoDiffUtility._normaliseVNode(rawVirtualDOM) end sub sub _onStateUpdated() diff --git a/src/components/renderer/KopytkoDOM.brs b/src/components/renderer/KopytkoDOM.brs index cb576b3..6784189 100644 --- a/src/components/renderer/KopytkoDOM.brs +++ b/src/components/renderer/KopytkoDOM.brs @@ -17,7 +17,7 @@ function KopytkoDOM() as Object return end if - if (vNodeType = "roAssociativeArray" AND vNode.name = Invalid) + if (vNodeType = "roAssociativeArray" AND vNode.__childrenMap = true) m._renderElementChildren(vNode, parentElement) return @@ -161,13 +161,13 @@ function KopytkoDOM() as Object if (children = Invalid) then return if (Type(children) = "roAssociativeArray") - ' Sort by order field before rendering — guarantees correct insertChild sequence - ' since roAssociativeArray does not preserve iteration order - orderedChildren = [] + if (children.__childrenMap <> true) then return - for each childId in children - child = children[childId] - if (child <> Invalid) then orderedChildren.push(child) + ' roAssociativeArray does not preserve the authored order - sort by the "order" field + ' assigned during normalisation to guarantee the correct insertChild sequence + orderedChildren = [] + for each childId in children.byId + orderedChildren.push(children.byId[childId]) end for orderedChildren.sortBy("order") @@ -175,8 +175,12 @@ function KopytkoDOM() as Object for each vChildNode in orderedChildren m.renderElement(vChildNode, parentElement) end for - else - ' Fallback — roArray (e.g. during first render before normalisation propagates) + + return + end if + + ' Raw (not yet normalised) array of children + if (Type(children) = "roArray") for each vChildNode in children if (vChildNode <> Invalid) m.renderElement(vChildNode, parentElement) diff --git a/src/components/renderer/KopytkoDiffUtility.brs b/src/components/renderer/KopytkoDiffUtility.brs index 29044f8..4cf1a23 100644 --- a/src/components/renderer/KopytkoDiffUtility.brs +++ b/src/components/renderer/KopytkoDiffUtility.brs @@ -6,12 +6,13 @@ function KopytkoDiffUtility() as Object ' Diffs the current virtual DOM against the new one, returning an object with the elements that need to be ' updated, rendered or removed. - ' @param {Object} currentVirtualDOM - The current virtual DOM - ' @param {Object} newVirtualDOM - The new virtual DOM + ' @param {Object} currentVirtualDOM - The current virtual DOM (normalised or raw) + ' @param {Object} newVirtualDOM - The new virtual DOM (raw render() output; normalised in place) ' @returns {Object} diffResult - The diff result object ' @returns {Object} diffResult.elementsToUpdate - vNodes containing the props that needs to be updated ' @returns {Object[]} diffResult.elementsToRender - A list of vNodes to render ' @returns {String[]} diffResult.elementsToRemove - A list of the IDs of elements to remove from the DOM + ' @returns {Object} diffResult.normalisedVirtualDOM - The normalised new virtual DOM to be stored for the next diff prototype.diffDOM = function (currentVirtualDOM as Object, newVirtualDOM as Object) as Object m._diffResult = { elementsToUpdate: {}, @@ -19,14 +20,13 @@ function KopytkoDiffUtility() as Object elementsToRemove: [], } - normalisedNew = m._normaliseVNode(newVirtualDOM) - currentIsCollection = m._isChildrenCollection(currentVirtualDOM) - newIsCollection = m._isChildrenCollection(normalisedNew) + normalisedCurrent = m.normaliseVNode(currentVirtualDOM) + normalisedNew = m.normaliseVNode(newVirtualDOM) - if (currentIsCollection AND newIsCollection) - m._diffElementChildren(currentVirtualDOM, normalisedNew) + if (m._isChildrenCollection(normalisedCurrent) AND m._isChildrenCollection(normalisedNew)) + m._diffElementChildren(normalisedCurrent, normalisedNew) else - m._diffElement(currentVirtualDOM, normalisedNew) + m._diffElement(normalisedCurrent, normalisedNew) end if diffResult = m._diffResult @@ -36,11 +36,38 @@ function KopytkoDiffUtility() as Object return diffResult end function + ' Normalises a render() output in place - every array of children becomes a { __childrenMap: true, byId: {} } + ' structure keyed by props.id, with an "order" field on each child preserving the array position. + ' Already normalised structures are returned unchanged, so the method is idempotent. + ' @param {Object} vNode - A single vNode, an array of vNodes, a normalised children map or Invalid + ' @returns {Object} The normalised vNode + prototype.normaliseVNode = function (vNode as Object) as Object + if (vNode = Invalid) then return Invalid + + ' render() returned a top-level array of vNodes + if (Type(vNode) = "roArray") + return m._normaliseChildArray(vNode) + end if + + if (Type(vNode) <> "roAssociativeArray") then return vNode + + ' Already normalised children map + if (vNode.__childrenMap = true) then return vNode + + ' Single vNode - normalise its children if still an array + if (vNode.children <> Invalid AND Type(vNode.children) = "roArray") + vNode.children = m._normaliseChildArray(vNode.children) + end if + + return vNode + end function + ' @private prototype._isChildrenCollection = function (vNode as Object) as Boolean if (vNode = Invalid) then return true if (Type(vNode) = "roArray") then return true if (Type(vNode) <> "roAssociativeArray") then return false + if (vNode.__childrenMap = true) then return true return (vNode.name = Invalid) end function @@ -80,7 +107,11 @@ function KopytkoDiffUtility() as Object newElement.dynamicProps = {} end if - m._diffElementProps(currentId, currentElement.dynamicProps, newElement.dynamicProps) + ' Props updates are keyed by the element id, so they can only be tracked for elements that have one + if (currentId <> Invalid) + m._diffElementProps(currentId, currentElement.dynamicProps, newElement.dynamicProps) + end if + m._diffElementChildren(currentElement.children, newElement.children, newId) end sub @@ -98,95 +129,98 @@ function KopytkoDiffUtility() as Object end for end sub + ' @todo Support elements reordering ' @private prototype._diffElementChildren = sub (currentChildren as Object, newChildren as Object, parentElementId = Invalid as Dynamic) - if (newChildren = Invalid) then newChildren = {} + currentById = m._getChildrenById(currentChildren) + newById = m._getChildrenById(newChildren) - if (Type(currentChildren) = "roArray") - currentChildren = m._normaliseChildArray(currentChildren) - end if + for each childId in newById + newChild = newById[childId] + newChild.index = newChild.order + newChild.parentId = parentElementId - for each childId in newChildren - newChild = newChildren[childId] - if (newChild <> Invalid AND Type(newChild) = "roAssociativeArray") - newChild.index = newChild.order - newChild.parentId = parentElementId + m._diffElement(currentById[childId], newChild) + end for - currentChild = Invalid - if (currentChildren <> Invalid) then currentChild = currentChildren[childId] - m._diffElement(currentChild, newChild) + for each childId in currentById + if (newById[childId] = Invalid) + m._markElementToBeRemoved(currentById[childId]) end if end for - - if (currentChildren <> Invalid) - for each childId in currentChildren - currentChild = currentChildren[childId] - if (newChildren[childId] = Invalid AND currentChild <> Invalid AND Type(currentChild) = "roAssociativeArray") - m._markElementToBeRemoved(currentChild) - end if - end for - end if end sub ' @private - prototype._markElementToBeRemoved = sub (element as Object) - if (element = Invalid OR Type(element) <> "roAssociativeArray") then return - if (element.props = Invalid OR element.props.id = Invalid) then return + ' Accepts Invalid, a raw vNode array or a normalised children map; returns an id-keyed AA of the vNodes + prototype._getChildrenById = function (children as Object) as Object + if (children = Invalid) then return {} + if (Type(children) = "roArray") then return m._normaliseChildArray(children).byId + if (Type(children) = "roAssociativeArray" AND children.__childrenMap = true) then return children.byId - m._diffResult.elementsToRemove.push(element.props.id) + return {} + end function - if (element.children = Invalid OR element.children.count() = 0) then return + ' @private + prototype._markElementToBeRemoved = sub (element as Object) + if (element = Invalid) then return - if (Type(element.children) = "roAssociativeArray") - for each childId in element.children - m._markElementToBeRemoved(element.children[childId]) - end for - else - for each child in element.children - if (child <> Invalid) - m._markElementToBeRemoved(child) - end if + if (Type(element) = "roArray") + for each child in element + m._markElementToBeRemoved(child) end for + + return end if - end sub - ' @private - prototype._normaliseVNode = function (vNode as Object) as Object - if (vNode = Invalid) then return Invalid + if (Type(element) <> "roAssociativeArray") then return - ' render() returned a top-level array of vNodes - if (Type(vNode) = "roArray") - return m._normaliseChildArray(vNode) - end if + if (element.__childrenMap = true) + for each childId in element.byId + m._markElementToBeRemoved(element.byId[childId]) + end for - if (Type(vNode) <> "roAssociativeArray") then return vNode + return + end if - ' Single vNode — normalise its children if still an array - if (vNode.children <> Invalid AND Type(vNode.children) = "roArray") - vNode.children = m._normaliseChildArray(vNode.children) + ' The id may be missing (e.g. when the whole render output shape changed) - children still have to be removed + if (element.props <> Invalid AND element.props.id <> Invalid) + m._diffResult.elementsToRemove.push(element.props.id) end if - return vNode - end function + m._markElementToBeRemoved(element.children) + end sub ' @private + ' The "order" field is only incremented for children added to the map, so it always matches the element's + ' final position between its siblings and can safely be used as the insertChild index. prototype._normaliseChildArray = function (children as Object) as Object - result = {} + byId = {} order = 0 + for each child in children if (child <> Invalid AND Type(child) = "roAssociativeArray") - if (child.props <> Invalid AND child.props.id <> Invalid) - child.order = order + if (child.props = Invalid OR child.props.id = Invalid) + print "Kopytko renderer: a child vNode has no props.id defined - it will not be rendered" + else + previousChild = byId[child.props.id] + if (previousChild <> Invalid) + print "Kopytko renderer: duplicated child vNode id '" + child.props.id + "' - only the last occurrence will be rendered" + child.order = previousChild.order + else + child.order = order + order++ + end if + if (child.children <> Invalid AND Type(child.children) = "roArray") child.children = m._normaliseChildArray(child.children) end if - result[child.props.id] = child + + byId[child.props.id] = child end if end if - order++ end for - return result + return { __childrenMap: true, byId: byId } end function return prototype diff --git a/src/components/renderer/_tests/kopytkoDOM/KopytkoDOM_renderElement.test.brs b/src/components/renderer/_tests/kopytkoDOM/KopytkoDOM_renderElement.test.brs index c9d377f..5183903 100644 --- a/src/components/renderer/_tests/kopytkoDOM/KopytkoDOM_renderElement.test.brs +++ b/src/components/renderer/_tests/kopytkoDOM/KopytkoDOM_renderElement.test.brs @@ -169,6 +169,71 @@ function TestSuite__KopytkoDOM_renderElement() as Object return ts.assertTrue(bothLabelsAreNotInvalid, "The array elements were not properly rendered") end function) + ts.addTest("it renders normalised map children in the order defined by their order field", function (ts as Object) as String + ' Given + ' The ids are chosen so that the alphabetical roAssociativeArray iteration order differs from the authored order + vNode = { + name: "LayoutGroup", + props: { id: "root" }, + children: { + __childrenMap: true, + byId: { + zebraLabel: { + name: "Label", + props: { id: "zebraLabel" }, + order: 0, + }, + alphaLabel: { + name: "Label", + props: { id: "alphaLabel" }, + order: 1, + }, + }, + }, + } + parentElement = CreateObject("roSGNode", "Group") + + ' When + ts.kopytkoDOM.renderElement(vNode, parentElement) + + ' Then + rootElement = parentElement.findNode("root") + actualChildrenIds = [rootElement.getChild(0).id, rootElement.getChild(1).id] + expectedChildrenIds = ["zebraLabel", "alphaLabel"] + + return ts.assertEqual(actualChildrenIds, expectedChildrenIds, "The normalised map children were not rendered in the authored order") + end function) + + ts.addTest("it renders all elements of a top-level normalised children map", function (ts as Object) as String + ' Given + vNode = { + __childrenMap: true, + byId: { + label1: { + name: "Label", + props: { id: "label1" }, + order: 0, + }, + label2: { + name: "Label", + props: { id: "label2" }, + order: 1, + }, + }, + } + parentElement = CreateObject("roSGNode", "Group") + + ' When + ts.kopytkoDOM.renderElement(vNode, parentElement) + + ' Then + label1 = parentElement.findNode("label1") + label2 = parentElement.findNode("label2") + bothLabelsAreNotInvalid = (label1 <> Invalid AND label2 <> Invalid) + + return ts.assertTrue(bothLabelsAreNotInvalid, "The children map elements were not properly rendered") + end function) + ts.addTest("it inits kopytko based element with its dynamic props", function (ts as Object) as String ' Given vNode = { diff --git a/src/components/renderer/_tests/kopytkoDiffUtility/KopytkoDiffUtility_Main.test.brs b/src/components/renderer/_tests/kopytkoDiffUtility/KopytkoDiffUtility_Main.test.brs index 80c6c6b..236030d 100644 --- a/src/components/renderer/_tests/kopytkoDiffUtility/KopytkoDiffUtility_Main.test.brs +++ b/src/components/renderer/_tests/kopytkoDiffUtility/KopytkoDiffUtility_Main.test.brs @@ -19,7 +19,7 @@ function TestSuite__KopytkoDiffUtility_Main() as Object diffResult = ts.kopytkoDiffUtility.diffDOM(vNode, newVNode) ' Then - expectedKeys = ["elementsToUpdate", "elementsToRender", "elementsToRemove"] + expectedKeys = ["elementsToUpdate", "elementsToRender", "elementsToRemove", "normalisedVirtualDOM"] return ts.assertAAHasKeys(diffResult, expectedKeys, "The diff result does not have the expected keys") end function) @@ -48,6 +48,7 @@ function TestSuite__KopytkoDiffUtility_Main() as Object elementsToUpdate: {}, elementsToRender: [], elementsToRemove: [], + normalisedVirtualDOM: newVNode, } return ts.assertEqual(diffResult, expectedResult, "The diff result is different than expected") @@ -182,6 +183,7 @@ function TestSuite__KopytkoDiffUtility_Main() as Object name: "Label", props: { id: "label2" }, parentid: "root", + order: 1, index: 1, } @@ -217,6 +219,7 @@ function TestSuite__KopytkoDiffUtility_Main() as Object name: "Label", props: { id: "label2" }, parentid: "root", + order: 1, index: 1, } @@ -275,8 +278,9 @@ function TestSuite__KopytkoDiffUtility_Main() as Object diffResult = ts.kopytkoDiffUtility.diffDOM(vNode, newVNode) expectedResult = { elementsToUpdate: {}, - elementsToRender: [newVNode.children[0]], + elementsToRender: [newVNode.children.byId.newLabel], elementsToRemove: ["label1"], + normalisedVirtualDOM: newVNode, } ' Then @@ -303,8 +307,9 @@ function TestSuite__KopytkoDiffUtility_Main() as Object diffResult = ts.kopytkoDiffUtility.diffDOM(vNode, newVNode) expectedResult = { elementsToUpdate: {}, - elementsToRender: [newVNode.children[0]], + elementsToRender: [newVNode.children.byId.button1], elementsToRemove: ["label1"], + normalisedVirtualDOM: newVNode, } ' Then @@ -331,8 +336,9 @@ function TestSuite__KopytkoDiffUtility_Main() as Object diffResult = ts.kopytkoDiffUtility.diffDOM(vNode, newVNode) expectedResult = { elementsToUpdate: {}, - elementsToRender: [newVNode.children[0]], + elementsToRender: [newVNode.children.byId.child1], elementsToRemove: ["child1"], + normalisedVirtualDOM: newVNode, } ' Then diff --git a/src/components/renderer/_tests/kopytkoDiffUtility/KopytkoDiffUtility_Normalisation.test.brs b/src/components/renderer/_tests/kopytkoDiffUtility/KopytkoDiffUtility_Normalisation.test.brs new file mode 100644 index 0000000..8c742d9 --- /dev/null +++ b/src/components/renderer/_tests/kopytkoDiffUtility/KopytkoDiffUtility_Normalisation.test.brs @@ -0,0 +1,264 @@ +function TestSuite__KopytkoDiffUtility_Normalisation() as Object + ts = KopytkoFrameworkTestSuite() + ts.name = "KopytkoDiffUtility - normalisation" + ts.kopytkoDiffUtility = KopytkoDiffUtility() + + ts.addTest("it returns the normalised virtual DOM in the diff result", function (ts as Object) as String + ' Given + vNode = TestUtil_createRootElementWithChildren([ + { + name: "Label", + props: { id: "label1" }, + }, + ]) + + newVNode = TestUtil_createRootElementWithChildren([ + { + name: "Label", + props: { id: "label1" }, + }, + ]) + + ' When + diffResult = ts.kopytkoDiffUtility.diffDOM(vNode, newVNode) + + ' Then + normalisedChildren = diffResult.normalisedVirtualDOM.children + + return ts.assertTrue(normalisedChildren.__childrenMap = true, "The children of the normalised virtual DOM are not a children map") + end function) + + ts.addTest("it produces no changes when diffing the stored normalised virtual DOM against an equivalent new tree", function (ts as Object) as String + ' Given + vNode = TestUtil_createRootElementWithChildren([ + { + name: "Label", + props: { id: "label1" }, + dynamicProps: { text: "some text" }, + }, + ]) + + firstNewVNode = TestUtil_createRootElementWithChildren([ + { + name: "Label", + props: { id: "label1" }, + dynamicProps: { text: "some text" }, + }, + ]) + + secondNewVNode = TestUtil_createRootElementWithChildren([ + { + name: "Label", + props: { id: "label1" }, + dynamicProps: { text: "some text" }, + }, + ]) + + ' When + firstDiffResult = ts.kopytkoDiffUtility.diffDOM(vNode, firstNewVNode) + secondDiffResult = ts.kopytkoDiffUtility.diffDOM(firstDiffResult.normalisedVirtualDOM, secondNewVNode) + + ' Then + expectedResult = { + elementsToUpdate: {}, + elementsToRender: [], + elementsToRemove: [], + normalisedVirtualDOM: secondNewVNode, + } + + return ts.assertEqual(secondDiffResult, expectedResult, "The second diff result is different than expected") + end function) + + ts.addTest("it assigns consecutive order values skipping Invalid children", function (ts as Object) as String + ' Given + children = [ + Invalid, + { + name: "Label", + props: { id: "label1" }, + }, + Invalid, + { + name: "Label", + props: { id: "label2" }, + }, + ] + + ' When + childrenMap = ts.kopytkoDiffUtility.normaliseVNode(children) + + ' Then + actualOrders = [childrenMap.byId.label1.order, childrenMap.byId.label2.order] + expectedOrders = [0, 1] + + return ts.assertEqual(actualOrders, expectedOrders, "The order values are not consecutive") + end function) + + ts.addTest("it does not include children without props.id in the normalised children map", function (ts as Object) as String + ' Given + children = [ + { + name: "Label", + }, + { + name: "Label", + props: { id: "label1" }, + }, + ] + + ' When + childrenMap = ts.kopytkoDiffUtility.normaliseVNode(children) + + ' Then + if (childrenMap.byId.count() <> 1) + return ts.fail("The children map contains a different number of children than expected") + end if + + return ts.assertTrue(childrenMap.byId.label1 <> Invalid, "The valid child is missing in the children map") + end function) + + ts.addTest("it keeps the last occurrence of a duplicated child id at the position of the first occurrence", function (ts as Object) as String + ' Given + children = [ + { + name: "Label", + props: { id: "label1", text: "first occurrence" }, + }, + { + name: "Label", + props: { id: "label2" }, + }, + { + name: "Label", + props: { id: "label1", text: "last occurrence" }, + }, + ] + + ' When + childrenMap = ts.kopytkoDiffUtility.normaliseVNode(children) + + ' Then + if (childrenMap.byId.label1.props.text <> "last occurrence") + return ts.fail("The last occurrence of the duplicated child did not win") + end if + + actualOrders = [childrenMap.byId.label1.order, childrenMap.byId.label2.order] + expectedOrders = [0, 1] + + return ts.assertEqual(actualOrders, expectedOrders, "The duplicated child did not keep the position of the first occurrence") + end function) + + ts.addTest("it removes all previous elements when the render output changes from a children collection to a single element", function (ts as Object) as String + ' Given + vNode = [ + { + name: "Group", + props: { id: "group1" }, + children: [ + { + name: "Label", + props: { id: "nestedLabel" }, + }, + ], + }, + { + name: "Group", + props: { id: "group2" }, + }, + ] + + newVNode = { + name: "Group", + props: { id: "newRoot" }, + } + + ' When + diffResult = ts.kopytkoDiffUtility.diffDOM(vNode, newVNode) + + ' Then + if (diffResult.elementsToRemove.count() <> 3) + return ts.fail("Not all previous elements were marked to be removed") + end if + + expectedElementIds = ["group1", "nestedLabel", "group2"] + for each elementId in expectedElementIds + result = ts.assertArrayContains(diffResult.elementsToRemove, elementId, "The '" + elementId + "' element was not marked to be removed") + if (result <> "") then return result + end for + + return ts.assertEqual(diffResult.elementsToRender, [newVNode], "The new element was not marked to be rendered") + end function) + + ts.addTest("it renders all new elements when the render output changes from a single element to a children collection", function (ts as Object) as String + ' Given + vNode = { + name: "Group", + props: { id: "oldRoot" }, + } + + newVNode = [ + { + name: "Group", + props: { id: "group1" }, + }, + { + name: "Group", + props: { id: "group2" }, + }, + ] + + ' When + diffResult = ts.kopytkoDiffUtility.diffDOM(vNode, newVNode) + + ' Then + result = ts.assertEqual(diffResult.elementsToRemove, ["oldRoot"], "The previous element was not marked to be removed") + if (result <> "") then return result + + return ts.assertEqual(diffResult.elementsToRender, [diffResult.normalisedVirtualDOM], "The new children collection was not marked to be rendered") + end function) + + ts.addTest("it supports child ids colliding with vNode field names", function (ts as Object) as String + ' Given + vNode = TestUtil_createRootElementWithChildren([ + { + name: "Label", + props: { id: "name" }, + dynamicProps: { text: "some text" }, + }, + { + name: "Label", + props: { id: "count" }, + }, + ]) + + newVNode = TestUtil_createRootElementWithChildren([ + { + name: "Label", + props: { id: "name" }, + dynamicProps: { text: "different text" }, + }, + { + name: "Label", + props: { id: "count" }, + }, + ]) + + ' When + diffResult = ts.kopytkoDiffUtility.diffDOM(vNode, newVNode) + + ' Then + if (diffResult.elementsToRender.count() <> 0 OR diffResult.elementsToRemove.count() <> 0) + return ts.fail("Elements with colliding ids were unexpectedly marked to be rendered or removed") + end if + + expectedElementsToUpdate = { + name: { + props: { text: "different text" }, + }, + } + + return ts.assertEqual(diffResult.elementsToUpdate, expectedElementsToUpdate, "The changed prop of the colliding id element was not marked to be updated") + end function) + + return ts +end function