
Chapter 11: Reconciliation & Commit โ Filling in the Engine's Blueprint
11.1 Standardizing the Node Format (Refactoring the h function)
In the last chapter, we set up the grand blueprint for the Fiber engine. Now, we're going to start writing code to fill in the two black boxes: performUnitOfWork and commitRoot. But before that, we need to ensure the data input to the engine is standardized.
You mean the Virtual DOM tree generated by the h() function?
Yes. In the old engine, plain text was often a bare string. In the Fiber engine, every node must be an object with type and props. If the traversal algorithm suddenly hits a string while processing the children array, the entire linked list structure will collapse.
So we need to wrap strings as objects too? Like giving them a standard shell.
Precisely. Let's refactor the h() function. We'll also use .flat() to handle nested arrays while we're at it:
function h(type, props, ...children) {
return {
type,
props: {
...props,
children: children.flat().map(child =>
typeof child === "object"
? child
// Standardize: Wrap non-object children as special TEXT_ELEMENT objects
: { type: "TEXT_ELEMENT", props: { nodeValue: child, children: [] } }
),
},
};
}Clever! TEXT_ELEMENT is our custom tag. This way, every node is a standard object, and the Fiber traversal algorithm can read type and props uniformly. Plus, text nodes have children: [], meaning they are leaf nodes and don't need further traversal.
11.2 Initializing the Engine State
The building blocks are ready. Now let's implement the global variables we discussed in the last chapter. Every call to the render function marks the moment we start drafting a "new blueprint" (Render Phase).
let currentRoot = null; // Finished Blueprint (currently on screen)
let wipRoot = null; // Draft Paper (new tree being built)
let workInProgress = null; // Traversal cursor
let deletions = null; // Trash bin: collects old nodes found to be deleted during comparison
function render(element, container) {
// 1. Create a brand-new root node for the draft
wipRoot = {
dom: container,
props: { children: [element] },
alternate: currentRoot, // ๐ Crucial: Connect to the finished blueprint via the alternate pointer
};
// 2. Empty the trash bin
deletions = [];
// 3. Set the cursor at the starting point, waiting for the workLoop to run
workInProgress = wipRoot;
}This is exactly like the diagram we drew! The draft paper (wipRoot) is ready and quietly connected to the original (alternate).
11.3 Unveiling the First Black Box: performUnitOfWork
When the browser is idle, the workLoop continuously calls performUnitOfWork. Each time it's called, it processes only one node. It needs to do three things:
- If the node doesn't have a real DOM yet, create it (but don't mount it).
- Reconcile: Create new Fiber nodes for its children and compare them with the old blueprint.
- Return the next node to process according to our "maze rules" (Down, Right, Up).
function performUnitOfWork(fiber) {
// 1. Create DOM (Don't mount yet, we're still in the draft stage!)
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
// 2. Reconcile children (The core logic inside the black box)
const elements = fiber.props.children;
reconcileChildren(fiber, elements);
// 3. Maze navigation rules: Return the next node
if (fiber.child) return fiber.child;
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) return nextFiber.sibling;
nextFiber = nextFiber.return;
}
return null;
}createDom should be simpleโjust create an element based on the type and set props, right? I'm more curious about the second step, reconcileChildren. How does it perform the comparison?
Reconciliation: The Inspector's Job
Imagine you are a quality inspector. In the reconcileChildren function, you hold a new list of child elements (from our h() function) in your left hand and the linked list of the old blueprintโs children (accessed via fiber.alternate.child) in your right hand.
You look at them both simultaneously, one by one, starting from the first child.
Left Hand = Elements in new draft Right Hand = Old Fiber from finished blueprint (oldFiber)If you find that the nodes on both sides have the same type (e.g., both are h1), what would you do?
Since the type is the same, I don't need to document.createElement again at all! I can just "move" the real DOM node from the old Fiber to the new draft and only update the changed props.
Perfect intuition. What if the type is different?
Then the old one is useless and I need to create a brand-new one.
Exactly. In the Render Phase, since we absolutely cannot touch the real page, the inspector's job is simply to apply labels. We add an attribute called effectTag to the newly created Fiber nodes:
- If the type is the same: Apply the
"UPDATE"tag. - If there's a new element but no old one (or types differ): Apply the
"PLACEMENT"tag (Add). - If there's an extra old element: Throw it into the
deletionstrash bin and apply the"DELETION"tag.
function reconcileChildren(wipFiber, elements) {
let index = 0;
// Get the first child Fiber in the old tree (Starting point for the "Right Hand")
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
// Continue looping as long as there are new elements or old nodes left
while (index < elements.length || oldFiber != null) {
const element = elements[index];
let newFiber = null;
// Compare if types are the same
const sameType = oldFiber && element && element.type === oldFiber.type;
if (sameType) {
// โ
Same type: Reuse old DOM, apply UPDATE tag
newFiber = {
type: oldFiber.type,
props: element.props,
dom: oldFiber.dom, // ๐ Reuse! Avoid expensive DOM creation
return: wipFiber,
alternate: oldFiber,
effectTag: "UPDATE",
};
}
if (element && !sameType) {
// ๐ New element found (or different type): Need brand new DOM, apply PLACEMENT tag
newFiber = {
type: element.type,
props: element.props,
dom: null,
return: wipFiber,
alternate: null,
effectTag: "PLACEMENT",
};
}
if (oldFiber && !sameType) {
// ๐๏ธ Extra old node: No corresponding new element, needs deletion
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber); // Toss it into the trash for the Commit Phase
}
// Move the cursor: Right Hand flips to the next old node
if (oldFiber) {
oldFiber = oldFiber.sibling;
}
// Link the newly generated Fibers into a list
if (index === 0) {
wipFiber.child = newFiber;
} else if (element) {
prevSibling.sibling = newFiber;
}
prevSibling = newFiber;
index++;
}
}I get it! reconcileChildren not only turns child elements into a Fiber linked list but also completes the comparison along the way. All operations affecting the page are abstracted into effectTag labels. The Render Phase really is just like "drafting"โnothing actually happens to the screen!
11.4 Unveiling the Second Black Box: commitRoot
When workInProgress becomes null, it means the draft is complete. All the labels are applied. Now we enter the Commit Phase. This phase is synchronous and finishes in one breath.
So this is where we actually do the work based on the labels. If we see PLACEMENT, we appendChild; if we see UPDATE, we change the props.
Yes. First, we process the deletion tasks in the deletions trash bin, then traverse the entire draft tree to execute all the tags.
function commitRoot() {
// 1. Clear out the nodes in the trash bin from the page first
deletions.forEach(commitWork);
// 2. Then process additions and updates on the new draft tree
commitWork(wipRoot.child);
// 3. Perfect finish! Set the draft as the new finished blueprint for the next update
currentRoot = wipRoot;
wipRoot = null;
}
function commitWork(fiber) {
if (!fiber) return;
// Find the parent node that has a real DOM
let domParentFiber = fiber.return;
while (!domParentFiber.dom) {
domParentFiber = domParentFiber.return;
}
const domParent = domParentFiber.dom;
// Do the work based on the labels
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent);
return; // โ ๏ธ Must return immediately after deletion! Stop traversing its children
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}Why do we return after a deletion and stop traversing?
If you cut the root of a tree, you don't need to worry about its branches. Continuing to traverse the subtree of a deleted old node is dangerous; they might have stale labels from the last update that could cause "zombie nodes" to come back to life.
I see! As for updateDom and createDom, I assume they use the same prop comparison logic we wrote for the old engine (Chapter 5), right? Adding new props, removing missing ones, and handling event listeners specially.
Exactly. Those low-level DOM operations haven't changed. Fiber changes "when to update" (switching from updating-while-traversing to unified commit), not "how to update the DOM."
11.5 The Changing of the Guard
Shifu, after breaking it down step-by-step, I feel so clear-headed. From Chapter 5 to Chapter 11, we've completely overhauled the engine's generation.
Let's have a final farewell with a comparison table:
| Concept | Old Engine (Stack Architecture) | New Engine (Fiber Architecture) |
|---|---|---|
| Workflow | Recursive traversal, uninterruptible | Time Slicing (workLoop), pause/resume at will |
| Phases | Update DOM while comparing | Divided into Render (Drafting) and Commit (Unified) |
| Data Structure | VNode Tree (children array) |
Fiber Linked List (child, sibling, return) |
| State Storage | Auto-saved by JS call stack | Explicitly saved by workInProgress cursor |
| Diff Markers | Direct modification of real DOM | Delayed ops via effectTag (UPDATE, PLACEMENT, DELETION) |
| Comparison | Pass old and new trees to patch |
Compare by connecting currentRoot via alternate pointer |
๐ฆ Try It Yourself
Open demoSave the following code as ch11.html. This is our first fully-formed Fiber engine with time slicing and complete reconciliation:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Chapter 11 โ The Complete Fiber Engine</title>
<style>
body { font-family: sans-serif; padding: 20px; }
h1 { color: #0066cc; }
button { padding: 8px 16px; font-size: 14px; cursor: pointer; }
#log { margin-top: 20px; font-family: monospace; background: #fdfdfd; border: 1px solid #ddd; padding: 10px; height: 150px; overflow-y: auto; }
</style>
</head>
<body>
<div id="app"></div>
<div id="log"></div>
<script>
const logEl = document.getElementById('log');
function log(msg) {
const p = document.createElement('div');
p.textContent = msg;
logEl.prepend(p);
}
// === 1. Standardize node format, auto-wrap text as TEXT_ELEMENT ===
function h(type, props, ...children) {
return {
type,
props: {
...props,
children: children.flat().map(child =>
typeof child === "object"
? child
: { type: "TEXT_ELEMENT", props: { nodeValue: child, children: [] } }
)
}
};
}
// === 2. Initialize Engine State ===
let currentRoot = null;
let wipRoot = null;
let workInProgress = null;
let deletions = [];
function render(element, container) {
wipRoot = {
dom: container,
props: { children: [element] },
alternate: currentRoot // Connect to finished blueprint
};
deletions = [];
workInProgress = wipRoot;
log('๐ render() triggered, starting Render Phase...');
}
// === 3. Time Slicing and Engine Heartbeat ===
function workLoop(deadline) {
let shouldYield = false;
while (workInProgress && !shouldYield) {
workInProgress = performUnitOfWork(workInProgress);
shouldYield = deadline.timeRemaining() < 1;
}
// Render Phase finished, enter Commit Phase
if (!workInProgress && wipRoot) {
log('โ
Render Phase complete! Entering Commit Phase...');
commitRoot();
}
requestIdleCallback(workLoop);
}
requestIdleCallback(workLoop);
// Keep browser active for stable idle callbacks
function keepAwake() { requestAnimationFrame(keepAwake); }
requestAnimationFrame(keepAwake);
// === 4. Black Box 1: Render Phase (Reconciliation & Drafting) ===
function performUnitOfWork(fiber) {
if (!fiber.dom) {
fiber.dom = createDom(fiber);
}
reconcileChildren(fiber, fiber.props.children);
// Return next node (Down -> Right -> Up)
if (fiber.child) return fiber.child;
let nextFiber = fiber;
while (nextFiber) {
if (nextFiber.sibling) return nextFiber.sibling;
nextFiber = nextFiber.return;
}
return null;
}
function reconcileChildren(wipFiber, elements) {
let index = 0;
let oldFiber = wipFiber.alternate && wipFiber.alternate.child;
let prevSibling = null;
while (index < elements.length || oldFiber != null) {
const element = elements[index];
let newFiber = null;
const sameType = oldFiber && element && element.type === oldFiber.type;
if (sameType) {
// Reuse old DOM
newFiber = {
type: oldFiber.type, props: element.props, dom: oldFiber.dom,
return: wipFiber, alternate: oldFiber, effectTag: "UPDATE"
};
}
if (element && !sameType) {
// Create new DOM
newFiber = {
type: element.type, props: element.props, dom: null,
return: wipFiber, alternate: null, effectTag: "PLACEMENT"
};
}
if (oldFiber && !sameType) {
// Mark for deletion
oldFiber.effectTag = "DELETION";
deletions.push(oldFiber);
}
if (oldFiber) oldFiber = oldFiber.sibling;
if (index === 0) wipFiber.child = newFiber;
else if (element) prevSibling.sibling = newFiber;
prevSibling = newFiber;
index++;
}
}
// === 5. Black Box 2: Commit Phase (Unified Update) ===
function commitRoot() {
deletions.forEach(commitWork);
commitWork(wipRoot.child);
currentRoot = wipRoot;
wipRoot = null;
log('๐ Commit Phase complete! Page updated.');
}
function commitWork(fiber) {
if (!fiber) return;
let domParentFiber = fiber.return;
while (!domParentFiber.dom) domParentFiber = domParentFiber.return;
const domParent = domParentFiber.dom;
if (fiber.effectTag === "PLACEMENT" && fiber.dom != null) {
domParent.appendChild(fiber.dom);
} else if (fiber.effectTag === "UPDATE" && fiber.dom != null) {
updateDom(fiber.dom, fiber.alternate.props, fiber.props);
} else if (fiber.effectTag === "DELETION") {
commitDeletion(fiber, domParent);
return;
}
commitWork(fiber.child);
commitWork(fiber.sibling);
}
function commitDeletion(fiber, domParent) {
if (fiber.dom) domParent.removeChild(fiber.dom);
else commitDeletion(fiber.child, domParent);
}
// === 6. Low-level DOM Ops ===
function createDom(fiber) {
const dom = fiber.type === "TEXT_ELEMENT"
? document.createTextNode("")
: document.createElement(fiber.type);
updateDom(dom, {}, fiber.props);
return dom;
}
function updateDom(dom, prevProps, nextProps) {
// Pass 1: Remove old props and event listeners
for (const key in prevProps) {
if (key === "children") continue;
if (!(key in nextProps) || prevProps[key] !== nextProps[key]) {
if (key.startsWith("on")) {
// Event listener: remove old
dom.removeEventListener(key.slice(2).toLowerCase(), prevProps[key]);
} else if (!(key in nextProps)) {
// Prop: old exists, new doesn't, clear it
dom[key] = "";
}
}
}
// Pass 2: Add or update new props and event listeners
for (const key in nextProps) {
if (key === "children") continue;
if (prevProps[key] !== nextProps[key]) {
if (key.startsWith("on")) {
dom.addEventListener(key.slice(2).toLowerCase(), nextProps[key]);
} else {
dom[key] = nextProps[key];
}
}
}
}
// === Application Demo ===
// Since Hooks aren't implemented yet, use global variable to simulate state
let counter = 1;
function getAppVNode() {
return h('div', { id: 'container' },
h('h1', null, 'Fiber Engine Running'),
h('p', null, `Current render count: ${counter}`),
h('button', { onclick: () => { counter++; renderApp(); } }, 'Trigger Fiber Update')
);
}
function renderApp() {
render(getAppVNode(), document.getElementById('app'));
}
// Initial mount
renderApp();
</script>
</body>
</html>Interactive Demo
Open demo
This page runs the original HTML demo for the chapter.
Open demo