--[[ 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\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