Didact Fiber: Incremental reconciliation – Hexacta Engineering

Besides Component class and createElement(), we’ll have two public functions: render() and setState(), and we saw that setState() just calls scheduleUpdate().

render() and scheduleUpdate() are similar, they receive a new update and queue it:

We’ll use the updateQueue array to keep track of the pending updates. Every call to render() or scheduleUpdate() pushes a new update to the updateQueue. The update information in each of the updates is different and we’ll see how we use it later in resetNextUnitOfWork().

After pushing the update to the queue, we trigger a deferred call to performWork().

Here’s where we use the performUnitOfWork() pattern that we saw earlier.

requestIdleCallback() calls the target function with a deadline as an argument. performWork() takes that deadline and pass it to workLoop(). After workLoop() returns, performWork() checks if there’s pending work. If there is, it schedules a new deferred call to itself.

workLoop() is the function that keeps an eye on the time. If the deadline is too close, it stops the work loop and leaves nextUnitOfWork updated so it can be resumed the next time.

We use ENOUGH_TIME (a 1ms constant, same as React’s) to check if deadline.timeRemaining() is enough to run another unit of work or not. If performUnitOfWork() takes more than that, we will overrun the deadline. The deadline is just a suggestion from the browser, so overrunning it for a few milliseconds is not that bad.

performUnitOfWork() will build the work-in-progress tree for the update it’s working on and also find out what changes we need to apply to the DOM. This will be done incrementally, one fiber at a time.

When performUnitOfWork() finishes all the work for the current update, it returns null and leaves the pending changes to the DOM in pendingCommit. Finally, commitAllWork() will take the effects from pendingCommit and mutate the DOM.

Note that commitAllWork() is called outside of the loop. The work done on performUnitOfWork() won’t mutate the DOM so it’s OK to split it. On the other hand, commitAllWork() will mutate the DOM, it should be done all at once to avoid an inconsistent UI.

We still haven’t seen where the first nextUnitOfWork comes from.

*should be resetNextUnitOfWork()

The function that takes an update and convert it to the first nextUnitOfWork is resetNextUnitOfWork():

resetNextUnitOfWork() starts by pulling the first update from the queue.

If the update has a partialState we store it on the fiber that belongs to the instance of the component, so we can use it later when we call component’s render().

Then we find the root of the old fiber tree. If the updates comes from the first time render() was called, we won’t have a root fiber so root will be null. If it comes from a subsequent call to render(), we can find the root on the _rootContainerFiber property of the DOM node. And if the update comes from a setState(), we need to go up from the instance fiber until we find a fiber without parent.

Then we assign to nextUnitOfWork a new fiber. This fiber is the root of a new work-in-progress tree.

If we don’t have an old root, the stateNode will be the DOM node received as parameter in the render() call. The props will be the newProps from the update: an object with a children property that has the elements â€" the other parameter of render(). The alternate will be null.

If we do have an old root, the stateNode will be the DOM node from the previous root. The props will be again newProps if not null, or else we copy the props from the old root. The alternate will be the old root.

We now have the root of the work-in-progress tree, let’s start building the rest of it.

performUnitOfWork() walks the work-in-progress tree.

We call beginWork() â€" to create the new children of a fiber â€" and then return the first child so it becomes the nextUnitOfWork.

If there isn’t any child, we call completeWork() and return the sibling as the nextUnitOfWork.

If there isn’t any sibling, we go up to the parents calling completeWork() until we find a sibling (that we’ll become the nextUnitOfWork) or until we reach the root.

Calling performUnitOfWork() multiple times will go down the tree creating the children of the first child of each fiber until it finds a fiber without children. Then it moves right doing the same with the siblings. And the it moves up doing the same with the uncles. (For a more vivid description try render some components on fiber-debugger)

beginWork() does two things:

  • create the stateNode if we don’t have one
  • get the component children and pass them to reconcileChildrenArray()

Because both depend on the type of component we are dealing with, we split it in two: updateHostComponent() and updateClassComponent().

updateHostComponent() handles host components and also the root component. It creates a new DOM node if it needs to (only one node, without children and without appending it to the DOM). Then it calls reconcileChildrenArray() using the child elements from the fiber props.

updateClassComponent() deals with class component instances. It creates a new instance calling the component constructor if it needs to. It updates the instance’s props and state so it can call the render() function to get the new children..

updateClassComponent() also validates if it makes sense to call render(). This is a simple version of shouldComponentUpdate(). If it looks like we don’t need to re-render, we just clone the current sub-tree to the work-in-progress tree without any reconciliation.

Now that we have the newChildElements, we are ready to create the child fibers for the work-in-progress fiber.

This is the heart of the library, where the work-in-progress tree grows and where we decide what changes we will do to the DOM on the commit phase.

Before starting we make sure newChildElements is an array. (Unlike the previous reconciliation algorithm, this one works always with arrays of children, this means we can now return arrays on component’s render() function)

Then we start comparing the children from the old fiber tree with the new elements (we compare fibers to elements). The children from the old fiber tree are the children of wipFiber.alternate. The new elements are the ones we got from the wipFiber.props.children or from calling wipFiber.stateNode.render().

Our reconciliation algorithm works by matching the first old fiber (wipFiber.alternate.child) with the first child element (elements[0]), the second old fiber (wipFiber.alternate..child.sibling) to the second child element (elements[1]) and so on. For each oldFiber-element pair:

  • If the oldFiber and the element have the same type, good news, it mean we can keep the old stateNode. We create a new fiber based on the old one. We add the UPDATE effectTag. And we append the new fiber to the work-in-progress tree.
  • If we have an element with a different type to the oldFiber or we don’t have an oldFiber (because we have more new children than old children), we create a new fiber with the information we have in the element. Note that this new fiber won’t have an alternate and won’t have a stateNode ( the stateNode we’ll be created in beginWork()). The effectTag for this fiber is PLACEMENT.
  • If the oldFiber and the element have a different type or there isn’t any element for this oldFiber (because we have more old children than new children) we tag the oldFiber for DELETION. Given that this fiber is not part of the work-in-progress tree, we need to add it now to the wipFiber.effects list so we don’t lose track of it.
Unlike React, we are not using keys to do the reconciliation, so we won’t know if a child moved from it’s previous position.

updateClassComponent() has a special case where we take a shortcut and clone the old fiber sub-tree to the work-in-progress tree instead of doing the reconciliation.

cloneChildFibers() clones each of the wipFiber.alternate children and appends them to the work-in-progress tree. We don’t need to add any effectTag because we are sure that nothing changed.

In performUnitOfWork(), when a wipFiber doesn’t have new children or when we already completed the work of all the children, we call completeWork().

completeWork() first updates the reference to the fiber related to the instance of a class component. (To be honest, this doesn’t really need to be here, but it has to be somewhere)

Then it build a list of effects. This list will contain all the fibers from the work-in-progress sub-tree that have any effectTag (it also contains the fibers from the old sub-tree with the DELETION effectTag). The idea is to accumulate in the root effects list all the fibers that have an effectTag.

Finally, if the fiber doesn’t have a parent, we are at the root of the work-in-progress tree. So we have completed all the work for this update and collected all the effects. We assign the root to pendingCommit so workLoop() can call commitAllWork().

There’s one last thing we need to do: mutate the DOM.

commitAllWork() first iterates all the root effects calling commitWork() on each one. commitWork() checks the effectTag of each fiber:

  • If it is a PLACEMENT we look for the parent DOM node and then simply append the fiber’s stateNode.
  • If it is an UPDATE we pass the stateNode together with the old props and the new props and let updateDomProperties() decide what to update.
  • If it is a DELETION and the fiber is a host component, it’s easy, we just call removeChild(). But if the fiber is a class component, before calling removeChild() we need to find all the host components from the fiber sub-tree that need to be removed.

Once we finished with all the effects, we can reset nextUnitOfWork and pendingCommit. The work-in-progress tree stops being the work-in-progress tree and becomes the old tree, so we assign its root to _rootContainerFiber. After that, we are done with the current update and we are ready to start the next one 🚀.

Previous Post
Next Post