Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/renderer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 4 additions & 2 deletions src/components/renderer/Kopytko.brs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ sub updateProps(props = {} as Object)
end sub

sub _mountComponent()
m._virtualDOM = render()
' 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)
Expand All @@ -110,7 +112,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)
Expand Down
52 changes: 43 additions & 9 deletions src/components/renderer/KopytkoDOM.brs
Original file line number Diff line number Diff line change
Expand Up @@ -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.__childrenMap = true)
m._renderElementChildren(vNode, parentElement)

return
Expand Down Expand Up @@ -150,15 +158,35 @@ function KopytkoDOM() as Object

' @private
prototype._renderElementChildren = sub (children as Object, parentElement as Object)
if (children = Invalid)
if (children = Invalid) then return

if (Type(children) = "roAssociativeArray")
if (children.__childrenMap <> true) then return

' 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")

for each vChildNode in orderedChildren
m.renderElement(vChildNode, parentElement)
end for

return
end if

for each vChildNode in children
if (vChildNode <> Invalid)
m.renderElement(vChildNode, parentElement)
end if
end for
' Raw (not yet normalised) array of children
if (Type(children) = "roArray")
for each vChildNode in children
if (vChildNode <> Invalid)
m.renderElement(vChildNode, parentElement)
end if
end for
end if
end sub

' @private
Expand All @@ -168,8 +196,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)
Expand Down
172 changes: 132 additions & 40 deletions src/components/renderer/KopytkoDiffUtility.brs
Original file line number Diff line number Diff line change
Expand Up @@ -6,31 +6,72 @@ 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: {},
elementsToRender: [],
elementsToRemove: [],
}

if (Type(currentVirtualDOM) = "roArray" AND (newVirtualDOM = Invalid OR Type(newVirtualDOM) = "roArray"))
m._diffElementChildren(currentVirtualDOM, newVirtualDOM)
normalisedCurrent = m.normaliseVNode(currentVirtualDOM)
normalisedNew = m.normaliseVNode(newVirtualDOM)

if (m._isChildrenCollection(normalisedCurrent) AND m._isChildrenCollection(normalisedNew))
m._diffElementChildren(normalisedCurrent, normalisedNew)
else
m._diffElement(currentVirtualDOM, newVirtualDOM)
m._diffElement(normalisedCurrent, normalisedNew)
end if

diffResult = m._diffResult
diffResult.normalisedVirtualDOM = normalisedNew
m.delete("_diffResult")

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

' @private
prototype._diffElement = sub (currentElement as Object, newElement as Object)
if (currentElement = Invalid AND newElement = Invalid)
Expand All @@ -49,14 +90,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)

Expand All @@ -67,8 +107,12 @@ 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)
' 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

' @private
Expand All @@ -88,48 +132,96 @@ function KopytkoDiffUtility() as Object
' @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)

currentChildrenMapped = {}
if (currentChildren <> Invalid)
for each currentChild in currentChildren
if (currentChild <> Invalid)
currentChildrenMapped[currentChild.props.id] = currentChild
end if
end for
end if

nonInvalidNewChildIndex = 0
for each newChild in newChildren
if (newChild <> Invalid)
newChild.index = nonInvalidNewChildIndex
nonInvalidNewChildIndex++
newChild.parentId = parentElementId
for each childId in newById
newChild = newById[childId]
newChild.index = newChild.order
newChild.parentId = parentElementId

m._diffElement(currentChildrenMapped[newChild.props.id], newChild)
currentChildrenMapped.delete(newChild.props.id)
end if
m._diffElement(currentById[childId], newChild)
end for

for each currentChildIdToRemove in currentChildrenMapped
m._markElementToBeRemoved(currentChildrenMapped[currentChildIdToRemove])
for each childId in currentById
if (newById[childId] = Invalid)
m._markElementToBeRemoved(currentById[childId])
end if
end for
end sub

' @private
' 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

return {}
end function

' @private
prototype._markElementToBeRemoved = sub (element as Object)
m._diffResult.elementsToRemove.push(element.props.id)
if (element = Invalid) then return

if (Type(element) = "roArray")
for each child in element
m._markElementToBeRemoved(child)
end for

if (element.children = Invalid OR element.children.count() = 0)
return
end if

for each child in element.children
if (child <> Invalid)
m._markElementToBeRemoved(child)
if (Type(element) <> "roAssociativeArray") then return

if (element.__childrenMap = true)
for each childId in element.byId
m._markElementToBeRemoved(element.byId[childId])
end for

return
end if

' 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

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
byId = {}
order = 0

for each child in children
if (child <> Invalid AND Type(child) = "roAssociativeArray")
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

byId[child.props.id] = child
end if
end if
end for
end sub

return { __childrenMap: true, byId: byId }
end function

return prototype
end function
Loading