Clients/Client2018/content/LuaPackages/RoactImpl/Reconciler.lua

544 lines
15 KiB
Lua

--[[
The reconciler uses the virtual DOM generated by components to create a real
tree of Roblox instances.
The reonciler has three basic operations:
* mount (previously reify)
* reconcile
* unmount (previously teardown)
Mounting is the process of creating new components. This is first
triggered when the user calls `Roact.mount` on an element. This is where the
structure of the component tree is built, later used and modified by the
reconciliation and unmounting steps.
Reconciliation accepts an existing concrete instance tree (created by mount)
along with a new element that describes the desired tree. The reconciler
will do the minimum amount of work required to update tree's components to
match the new element, sometimes invoking mount to create new branches.
Unmounting destructs for the tree. It will crawl through the tree,
destroying nodes from the bottom up.
Much of the reconciler's work is done by Component, which is the base for
all stateful components in Roact. Components can trigger reconciliation (and
implicitly, unmounting) via state updates that come with their own caveats.
]]
local Core = require(script.Parent.Core)
local Event = require(script.Parent.Event)
local Change = require(script.Parent.Change)
local getDefaultPropertyValue = require(script.Parent.getDefaultPropertyValue)
local SingleEventManager = require(script.Parent.SingleEventManager)
local Symbol = require(script.Parent.Symbol)
local isInstanceHandle = Symbol.named("isInstanceHandle")
local DEFAULT_SOURCE = "\n\t<Use Roact.setGlobalConfig with the 'elementTracing' key to enable detailed tracebacks>\n"
local function isPortal(element)
if type(element) ~= "table" then
return false
end
return element.component == Core.Portal
end
--[[
Sets the value of a reference to a new rendered object.
Correctly handles both function-style and object-style refs.
]]
local function applyRef(ref, newRbx)
if ref == nil then
return
end
if type(ref) == "table" then
ref.current = newRbx
else
ref(newRbx)
end
end
local Reconciler = {}
Reconciler._singleEventManager = SingleEventManager.new()
--[[
Is this element backed by a Roblox instance directly?
]]
local function isPrimitiveElement(element)
if type(element) ~= "table" then
return false
end
return type(element.component) == "string"
end
--[[
Is this element defined by a pure function?
]]
local function isFunctionalElement(element)
if type(element) ~= "table" then
return false
end
return type(element.component) == "function"
end
--[[
Is this element defined by a component class?
]]
local function isStatefulElement(element)
if type(element) ~= "table" then
return false
end
return type(element.component) == "table"
end
--[[
Destroy the given Roact instance, all of its descendants, and associated
Roblox instances owned by the components.
]]
function Reconciler.unmount(instanceHandle)
local element = instanceHandle._element
if isPrimitiveElement(element) then
-- We're destroying a Roblox Instance-based object
-- Kill refs before we make changes, since any mutations past this point
-- aren't relevant to components.
applyRef(element.props[Core.Ref], nil)
for _, child in pairs(instanceHandle._children) do
Reconciler.unmount(child)
end
-- Necessary to make sure SingleEventManager doesn't leak references
Reconciler._singleEventManager:disconnectAll(instanceHandle._rbx)
instanceHandle._rbx:Destroy()
elseif isFunctionalElement(element) then
-- Functional components can return nil
if instanceHandle._child then
Reconciler.unmount(instanceHandle._child)
end
elseif isStatefulElement(element) then
instanceHandle._instance:_unmount()
elseif isPortal(element) then
for _, child in pairs(instanceHandle._children) do
Reconciler.unmount(child)
end
else
error(("Cannot unmount invalid Roact instance %q"):format(tostring(element)))
end
end
--[[
Public interface to reifier. Hides parameters used when recursing down the
component tree.
]]
function Reconciler.mount(element, parent, key)
return Reconciler._mountInternal(element, parent, key)
end
--[[
Instantiates components to represent the given element.
Parameters:
- `element`: The element to mount.
- `parent`: The Roblox object to contain the contained instances
- `key`: The Name to give the Roblox instance that gets created
- `context`: Used to pass Roact context values down the tree
The structure created by this method is important to the functionality of
the reconciliation methods; they depend on this structure being well-formed.
]]
function Reconciler._mountInternal(element, parent, key, context)
if isPrimitiveElement(element) then
-- Primitive elements are backed directly by Roblox Instances.
local rbx = Instance.new(element.component)
-- Update Roblox properties
for key, value in pairs(element.props) do
Reconciler._setRbxProp(rbx, key, value, element)
end
-- Create children!
local children = {}
if element.props[Core.Children] then
for key, childElement in pairs(element.props[Core.Children]) do
local childInstance = Reconciler._mountInternal(childElement, rbx, key, context)
children[key] = childInstance
end
end
-- This name can be passed through multiple components.
-- Elements with the same key will be treated as the same
-- element between reconciles; the old element will be
-- reconciled to the new element with the same key.
if key then
rbx.Name = key
end
rbx.Parent = parent
-- Attach ref values, since the instance is initialized now.
applyRef(element.props[Core.Ref], rbx)
return {
[isInstanceHandle] = true,
_key = key,
_parent = parent,
_element = element,
_context = context,
_children = children,
_rbx = rbx,
}
elseif isFunctionalElement(element) then
-- Functional elements contain 0 or 1 children.
local instanceHandle = {
[isInstanceHandle] = true,
_key = key,
_parent = parent,
_element = element,
_context = context,
}
local vdom = element.component(element.props)
if vdom then
instanceHandle._child = Reconciler._mountInternal(vdom, parent, key, context)
end
return instanceHandle
elseif isStatefulElement(element) then
-- Stateful elements have 0 or 1 children, and also have a backing
-- instance that can keep state.
-- We separate the instance's implementation from our handle to it.
local instanceHandle = {
[isInstanceHandle] = true,
_key = key,
_parent = parent,
_element = element,
_child = nil,
}
local instance = element.component._new(element.props, context)
instanceHandle._instance = instance
instance:_mount(instanceHandle)
return instanceHandle
elseif isPortal(element) then
-- Portal elements have one or more children.
local target = element.props.target
if not target then
error(("Cannot mount Portal without specifying a target."):format(tostring(element)))
elseif typeof(target) ~= "Instance" then
error(("Cannot mount Portal with target of type %q."):format(typeof(target)))
end
-- Create children!
local children = {}
if element.props[Core.Children] then
for key, childElement in pairs(element.props[Core.Children]) do
local childInstance = Reconciler._mountInternal(childElement, target, key, context)
children[key] = childInstance
end
end
return {
[isInstanceHandle] = true,
_key = key,
_parent = parent,
_element = element,
_context = context,
_children = children,
_rbx = target,
}
elseif typeof(element) == "boolean" then
-- Ignore booleans of either value
-- See https://github.com/Roblox/roact/issues/14
return nil
end
error(("Cannot mount invalid Roact element %q"):format(tostring(element)))
end
--[[
A public interface around _reconcileInternal
]]
function Reconciler.reconcile(instanceHandle, newElement)
if instanceHandle == nil or not instanceHandle[isInstanceHandle] then
local message = (
"Bad argument #1 to Reconciler.reconcile, expected component instance handle, found %s"
):format(
typeof(instanceHandle)
)
error(message, 2)
end
return Reconciler._reconcileInternal(instanceHandle, newElement)
end
--[[
Applies the state given by newElement to an existing Roact instance.
reconcile will return the instance that should be used. This instance can
be different than the one that was passed in.
]]
function Reconciler._reconcileInternal(instanceHandle, newElement)
local oldElement = instanceHandle._element
-- Instance was deleted!
if not newElement then
Reconciler.unmount(instanceHandle)
return nil
end
-- If the element changes type, we assume its subtree will be substantially
-- different. This lets us skip comparisons of a large swath of nodes.
if oldElement.component ~= newElement.component then
local parent = instanceHandle._parent
local key = instanceHandle._key
local context
if isStatefulElement(oldElement) then
context = instanceHandle._instance._context
else
context = instanceHandle._context
end
Reconciler.unmount(instanceHandle)
local newInstance = Reconciler._mountInternal(newElement, parent, key, context)
return newInstance
end
if isPrimitiveElement(newElement) then
-- Roblox Instance change
local oldRef = oldElement.props[Core.Ref]
local newRef = newElement.props[Core.Ref]
-- Change the ref in one pass before applying any changes.
-- Roact doesn't provide any guarantees with regards to the sequencing
-- between refs and other changes in the commit phase.
if newRef ~= oldRef then
applyRef(oldRef, nil)
applyRef(newRef, instanceHandle._rbx)
end
-- Update properties and children of the Roblox object.
Reconciler._reconcilePrimitiveProps(oldElement, newElement, instanceHandle._rbx)
Reconciler._reconcilePrimitiveChildren(instanceHandle, newElement)
instanceHandle._element = newElement
return instanceHandle
elseif isFunctionalElement(newElement) then
instanceHandle._element = newElement
local rendered = newElement.component(newElement.props)
local newChild
if instanceHandle._child then
-- Transition from tree to tree, even if 'rendered' is nil
newChild = Reconciler._reconcileInternal(instanceHandle._child, rendered)
elseif rendered then
-- Transition from nil to new tree
newChild = Reconciler._mountInternal(
rendered,
instanceHandle._parent,
instanceHandle._key,
instanceHandle._context
)
end
instanceHandle._child = newChild
return instanceHandle
elseif isStatefulElement(newElement) then
instanceHandle._element = newElement
-- Stateful elements can take care of themselves.
instanceHandle._instance:_update(newElement.props)
return instanceHandle
elseif isPortal(newElement) then
if instanceHandle._rbx ~= newElement.props.target then
local parent = instanceHandle._parent
local key = instanceHandle._key
local context = instanceHandle._context
Reconciler.unmount(instanceHandle)
local newInstance = Reconciler._mountInternal(newElement, parent, key, context)
return newInstance
end
Reconciler._reconcilePrimitiveChildren(instanceHandle, newElement)
instanceHandle._element = newElement
return instanceHandle
end
error(("Cannot reconcile to match invalid Roact element %q"):format(tostring(newElement)))
end
--[[
Reconciles the children of an existing Roact instance and the given element.
]]
function Reconciler._reconcilePrimitiveChildren(instance, newElement)
local elementChildren = newElement.props[Core.Children]
-- Reconcile existing children that were changed or removed
for key, childInstance in pairs(instance._children) do
local childElement = elementChildren and elementChildren[key]
childInstance = Reconciler._reconcileInternal(childInstance, childElement)
instance._children[key] = childInstance
end
-- Create children that were just added!
if elementChildren then
for key, childElement in pairs(elementChildren) do
-- Update if we didn't hit the child in the previous loop
if not instance._children[key] then
local childInstance = Reconciler._mountInternal(childElement, instance._rbx, key, instance._context)
instance._children[key] = childInstance
end
end
end
end
--[[
Reconciles the properties between two primitive Roact elements and applies
the differences to the given Roblox object.
]]
function Reconciler._reconcilePrimitiveProps(fromElement, toElement, rbx)
local seenProps = {}
-- Set properties that were set with fromElement
for key, oldValue in pairs(fromElement.props) do
seenProps[key] = true
local newValue = toElement.props[key]
-- Assume any property that can be set to nil has a default value of nil
if newValue == nil then
local _, value = getDefaultPropertyValue(rbx.ClassName, key)
-- We don't care if getDefaultPropertyValue fails, because
-- _setRbxProp will catch the error below.
newValue = value
end
-- Roblox does this check for normal values, but we have special
-- properties like events that warrant this.
if oldValue ~= newValue then
Reconciler._setRbxProp(rbx, key, newValue, toElement)
end
end
-- Set properties that are new in toElement
for key, newValue in pairs(toElement.props) do
if not seenProps[key] then
seenProps[key] = true
local oldValue = fromElement.props[key]
if oldValue ~= newValue then
Reconciler._setRbxProp(rbx, key, newValue, toElement)
end
end
end
end
--[[
Used in _setRbxProp to avoid creating a new closure for every property set.
]]
local function set(rbx, key, value)
rbx[key] = value
end
--[[
Sets a property on a Roblox object, following Roact's rules for special
case properties.
This function can throw a couple different errors. In the future, calls to
_setRbxProp should be wrapped in a pcall to give better errors to the user.
For that to be useful, we'll need to attach a 'source' property on every
element, created using debug.traceback(), that points to where the element
was created.
]]
function Reconciler._setRbxProp(rbx, key, value, element)
if type(key) == "string" then
-- Regular property
local success, err = pcall(set, rbx, key, value)
if not success then
local source = element.source or DEFAULT_SOURCE
local message = ("Failed to set property %s on primitive instance of class %s\n%s\n%s"):format(
key,
rbx.ClassName,
err,
source
)
error(message, 0)
end
elseif type(key) == "table" then
-- Special property with extra data attached.
if key.type == Event then
Reconciler._singleEventManager:connect(rbx, key.name, value)
elseif key.type == Change then
Reconciler._singleEventManager:connectProperty(rbx, key.name, value)
else
local source = element.source or DEFAULT_SOURCE
-- luacheck: ignore 6
local message = ("Failed to set special property on primitive instance of class %s\nInvalid special property type %q\n%s"):format(
rbx.ClassName,
tostring(key.type),
source
)
error(message, 0)
end
elseif type(key) ~= "userdata" then
-- Userdata values are special markers, usually created by Symbol
-- They have no data attached other than being unique keys
local source = element.source or DEFAULT_SOURCE
local message = ("Properties with a key type of %q are not supported\n%s"):format(
type(key),
source
)
error(message, 0)
end
end
return Reconciler