diff --git a/src/component.js b/src/component.js index e29abae..278ac4a 100644 --- a/src/component.js +++ b/src/component.js @@ -1,26 +1,18 @@ -import { reconcile } from "./reconciler"; +import { scheduleUpdate } from "./reconciler"; export class Component { constructor(props) { - this.props = props; + this.props = props || {}; this.state = this.state || {}; } setState(partialState) { - this.state = Object.assign({}, this.state, partialState); - updateInstance(this.__internalInstance); + scheduleUpdate(this, partialState); } } -function updateInstance(internalInstance) { - const parentDom = internalInstance.dom.parentNode; - const element = internalInstance.element; - reconcile(parentDom, internalInstance, element); -} - -export function createPublicInstance(element, internalInstance) { - const { type, props } = element; - const publicInstance = new type(props); - publicInstance.__internalInstance = internalInstance; - return publicInstance; +export function createInstance(fiber) { + const instance = new fiber.type(fiber.props); + instance.__fiber = fiber; + return instance; } diff --git a/src/dom-utils.js b/src/dom-utils.js index 9c4edc2..236c1c7 100644 --- a/src/dom-utils.js +++ b/src/dom-utils.js @@ -1,26 +1,66 @@ -export function updateDomProperties(dom, prevProps, nextProps) { - const isEvent = name => name.startsWith("on"); - const isAttribute = name => !isEvent(name) && name != "children"; +import { TEXT_ELEMENT } from "./element"; + +const isEvent = name => name.startsWith("on"); +const isAttribute = name => + !isEvent(name) && name != "children" && name != "style"; +const isNew = (prev, next) => key => prev[key] !== next[key]; +const isGone = (prev, next) => key => !(key in next); +export function updateDomProperties(dom, prevProps, nextProps) { // Remove event listeners - Object.keys(prevProps).filter(isEvent).forEach(name => { - const eventType = name.toLowerCase().substring(2); - dom.removeEventListener(eventType, prevProps[name]); - }); + Object.keys(prevProps) + .filter(isEvent) + .filter(key => !(key in nextProps) || isNew(prevProps, nextProps)(key)) + .forEach(name => { + const eventType = name.toLowerCase().substring(2); + dom.removeEventListener(eventType, prevProps[name]); + }); // Remove attributes - Object.keys(prevProps).filter(isAttribute).forEach(name => { - dom[name] = null; - }); + Object.keys(prevProps) + .filter(isAttribute) + .filter(isGone(prevProps, nextProps)) + .forEach(name => { + dom[name] = null; + }); // Set attributes - Object.keys(nextProps).filter(isAttribute).forEach(name => { - dom[name] = nextProps[name]; - }); + Object.keys(nextProps) + .filter(isAttribute) + .filter(isNew(prevProps, nextProps)) + .forEach(name => { + dom[name] = nextProps[name]; + }); + + // Set style + prevProps.style = prevProps.style || {}; + nextProps.style = nextProps.style || {}; + Object.keys(nextProps.style) + .filter(isNew(prevProps.style, nextProps.style)) + .forEach(key => { + dom.style[key] = nextProps.style[key]; + }); + Object.keys(prevProps.style) + .filter(isGone(prevProps.style, nextProps.style)) + .forEach(key => { + dom.style[key] = ""; + }); // Add event listeners - Object.keys(nextProps).filter(isEvent).forEach(name => { - const eventType = name.toLowerCase().substring(2); - dom.addEventListener(eventType, nextProps[name]); - }); + Object.keys(nextProps) + .filter(isEvent) + .filter(isNew(prevProps, nextProps)) + .forEach(name => { + const eventType = name.toLowerCase().substring(2); + dom.addEventListener(eventType, nextProps[name]); + }); +} + +export function createDomElement(fiber) { + const isTextElement = fiber.type === TEXT_ELEMENT; + const dom = isTextElement + ? document.createTextNode("") + : document.createElement(fiber.type); + updateDomProperties(dom, [], fiber.props); + return dom; } diff --git a/src/reconciler.js b/src/reconciler.js index cfd5377..6f0e19d 100644 --- a/src/reconciler.js +++ b/src/reconciler.js @@ -1,93 +1,291 @@ -import { updateDomProperties } from "./dom-utils"; -import { TEXT_ELEMENT } from "./element"; -import { createPublicInstance } from "./component"; +import { createDomElement, updateDomProperties } from "./dom-utils"; +import { createInstance } from "./component"; -let rootInstance = null; +// Fiber tags +const HOST_COMPONENT = "host"; +const CLASS_COMPONENT = "class"; +const HOST_ROOT = "root"; -export function render(element, container) { - const prevInstance = rootInstance; - const nextInstance = reconcile(container, prevInstance, element); - rootInstance = nextInstance; +// Effect tags +const PLACEMENT = 1; +const DELETION = 2; +const UPDATE = 3; + +const ENOUGH_TIME = 1; + +// Global state +const updateQueue = []; +let nextUnitOfWork = null; +let pendingCommit = null; + +export function render(elements, containerDom) { + updateQueue.push({ + from: HOST_ROOT, + dom: containerDom, + newProps: { children: elements } + }); + requestIdleCallback(performWork); } -export function reconcile(parentDom, instance, element) { - if (instance == null) { - // Create instance - const newInstance = instantiate(element); - parentDom.appendChild(newInstance.dom); - return newInstance; - } else if (element == null) { - // Remove instance - parentDom.removeChild(instance.dom); - return null; - } else if (instance.element.type !== element.type) { - // Replace instance - const newInstance = instantiate(element); - parentDom.replaceChild(newInstance.dom, instance.dom); - return newInstance; - } else if (typeof element.type === "string") { - // Update dom instance - updateDomProperties(instance.dom, instance.element.props, element.props); - instance.childInstances = reconcileChildren(instance, element); - instance.element = element; - return instance; +export function scheduleUpdate(instance, partialState) { + updateQueue.push({ + from: CLASS_COMPONENT, + instance: instance, + partialState: partialState + }); + requestIdleCallback(performWork); +} + +function performWork(deadline) { + workLoop(deadline); + if (nextUnitOfWork || updateQueue.length > 0) { + requestIdleCallback(performWork); + } +} + +function workLoop(deadline) { + if (!nextUnitOfWork) { + resetNextUnitOfWork(); + } + while (nextUnitOfWork) { + nextUnitOfWork = performUnitOfWork(nextUnitOfWork); + } + if (pendingCommit) { + commitAllWork(pendingCommit); + } +} + +function resetNextUnitOfWork() { + const update = updateQueue.shift(); + if (!update) { + return; + } + + // Copy the setState parameter from the update payload to the corresponding fiber + if (update.partialState) { + update.instance.__fiber.partialState = update.partialState; + } + + const root = + update.from == HOST_ROOT + ? update.dom._rootContainerFiber + : getRoot(update.instance.__fiber); + + nextUnitOfWork = { + tag: HOST_ROOT, + stateNode: update.dom || root.stateNode, + props: update.newProps || root.props, + alternate: root + }; +} + +function getRoot(fiber) { + let node = fiber; + while (node.parent) { + node = node.parent; + } + return node; +} + +function performUnitOfWork(wipFiber) { + beginWork(wipFiber); + if (wipFiber.child) { + return wipFiber.child; + } + + // No child, we call completeWork until we find a sibling + let uow = wipFiber; + while (uow) { + completeWork(uow); + if (uow.sibling) { + // Sibling needs to beginWork + return uow.sibling; + } + uow = uow.parent; + } +} + +function beginWork(wipFiber) { + if (wipFiber.tag == CLASS_COMPONENT) { + updateClassComponent(wipFiber); } else { - //Update composite instance - instance.publicInstance.props = element.props; - const childElement = instance.publicInstance.render(); - const oldChildInstance = instance.childInstance; - const childInstance = reconcile(parentDom, oldChildInstance, childElement); - instance.dom = childInstance.dom; - instance.childInstance = childInstance; - instance.element = element; - return instance; - } -} - -function reconcileChildren(instance, element) { - const dom = instance.dom; - const childInstances = instance.childInstances; - const nextChildElements = element.props.children || []; - const newChildInstances = []; - const count = Math.max(childInstances.length, nextChildElements.length); - for (let i = 0; i < count; i++) { - const childInstance = childInstances[i]; - const childElement = nextChildElements[i]; - const newChildInstance = reconcile(dom, childInstance, childElement); - newChildInstances.push(newChildInstance); - } - return newChildInstances.filter(instance => instance != null); -} - -function instantiate(element) { - const { type, props } = element; - const isDomElement = typeof type === "string"; - - if (isDomElement) { - // Instantiate DOM element - const isTextElement = type === TEXT_ELEMENT; - const dom = isTextElement - ? document.createTextNode("") - : document.createElement(type); - - updateDomProperties(dom, [], props); - - const childElements = props.children || []; - const childInstances = childElements.map(instantiate); - const childDoms = childInstances.map(childInstance => childInstance.dom); - childDoms.forEach(childDom => dom.appendChild(childDom)); - - const instance = { dom, element, childInstances }; - return instance; + updateHostComponent(wipFiber); + } +} + +function updateHostComponent(wipFiber) { + if (!wipFiber.stateNode) { + wipFiber.stateNode = createDomElement(wipFiber); + } + + const newChildElements = wipFiber.props.children; + reconcileChildrenArray(wipFiber, newChildElements); +} + +function updateClassComponent(wipFiber) { + let instance = wipFiber.stateNode; + if (instance == null) { + // Call class constructor + instance = wipFiber.stateNode = createInstance(wipFiber); + } else if (wipFiber.props == instance.props && !wipFiber.partialState) { + // No need to render, clone children from last time + cloneChildFibers(wipFiber); + return; + } + + instance.props = wipFiber.props; + instance.state = Object.assign({}, instance.state, wipFiber.partialState); + wipFiber.partialState = null; + + const newChildElements = wipFiber.stateNode.render(); + reconcileChildrenArray(wipFiber, newChildElements); +} + +function arrify(val) { + return val == null ? [] : Array.isArray(val) ? val : [val]; +} + +function reconcileChildrenArray(wipFiber, newChildElements) { + const elements = arrify(newChildElements); + + let index = 0; + let oldFiber = wipFiber.alternate ? wipFiber.alternate.child : null; + let newFiber = null; + while (index < elements.length || oldFiber != null) { + const prevFiber = newFiber; + const element = index < elements.length && elements[index]; + const sameType = oldFiber && element && element.type == oldFiber.type; + + if (sameType) { + newFiber = { + type: oldFiber.type, + tag: oldFiber.tag, + stateNode: oldFiber.stateNode, + props: element.props, + parent: wipFiber, + alternate: oldFiber, + partialState: oldFiber.partialState, + effectTag: UPDATE + }; + } + + if (element && !sameType) { + newFiber = { + type: element.type, + tag: + typeof element.type === "string" ? HOST_COMPONENT : CLASS_COMPONENT, + props: element.props, + parent: wipFiber, + effectTag: PLACEMENT + }; + } + + if (oldFiber && !sameType) { + oldFiber.effectTag = DELETION; + wipFiber.effects = wipFiber.effects || []; + wipFiber.effects.push(oldFiber); + } + + if (oldFiber) { + oldFiber = oldFiber.sibling; + } + + if (index == 0) { + wipFiber.child = newFiber; + } else if (prevFiber && element) { + prevFiber.sibling = newFiber; + } + + index++; + } +} + +function cloneChildFibers(parentFiber) { + const oldFiber = parentFiber.alternate; + if (!oldFiber.child) { + return; + } + + let oldChild = oldFiber.child; + let prevChild = null; + while (oldChild) { + const newChild = { + type: oldChild.type, + tag: oldChild.tag, + stateNode: oldChild.stateNode, + props: oldChild.props, + partialState: oldChild.partialState, + alternate: oldChild, + parent: parentFiber + }; + if (prevChild) { + prevChild.sibling = newChild; + } else { + parentFiber.child = newChild; + } + prevChild = newChild; + oldChild = oldChild.sibling; + } +} + +function completeWork(fiber) { + if (fiber.tag == CLASS_COMPONENT) { + fiber.stateNode.__fiber = fiber; + } + + if (fiber.parent) { + const childEffects = fiber.effects || []; + const thisEffect = fiber.effectTag != null ? [fiber] : []; + const parentEffects = fiber.parent.effects || []; + fiber.parent.effects = parentEffects.concat(childEffects, thisEffect); } else { - // Instantiate component element - const instance = {}; - const publicInstance = createPublicInstance(element, instance); - const childElement = publicInstance.render(); - const childInstance = instantiate(childElement); - const dom = childInstance.dom; - - Object.assign(instance, { dom, element, childInstance, publicInstance }); - return instance; + pendingCommit = fiber; + } +} + +function commitAllWork(fiber) { + fiber.effects.forEach(f => { + commitWork(f); + }); + fiber.stateNode._rootContainerFiber = fiber; + nextUnitOfWork = null; + pendingCommit = null; +} + +function commitWork(fiber) { + if (fiber.tag == HOST_ROOT) { + return; + } + + let domParentFiber = fiber.parent; + while (domParentFiber.tag == CLASS_COMPONENT) { + domParentFiber = domParentFiber.parent; + } + const domParent = domParentFiber.stateNode; + + if (fiber.effectTag == PLACEMENT && fiber.tag == HOST_COMPONENT) { + domParent.appendChild(fiber.stateNode); + } else if (fiber.effectTag == UPDATE) { + updateDomProperties(fiber.stateNode, fiber.alternate.props, fiber.props); + } else if (fiber.effectTag == DELETION) { + commitDeletion(fiber, domParent); + } +} + +function commitDeletion(fiber, domParent) { + let node = fiber; + while (true) { + if (node.tag == CLASS_COMPONENT) { + node = node.child; + continue; + } + domParent.removeChild(node.stateNode); + while (node != fiber && !node.sibling) { + node = node.parent; + } + if (node == fiber) { + return; + } + node = node.sibling; } } diff --git a/test/00.render-dom-elements.test.js b/test/00.render-dom-elements.test.js index 055aed4..44a8dbb 100644 --- a/test/00.render-dom-elements.test.js +++ b/test/00.render-dom-elements.test.js @@ -1,10 +1,7 @@ import test from "ava"; -import browserEnv from "browser-env"; +import "./_browser-mock"; import { render } from "../src/didact"; -// Create document global var -browserEnv(["document"]); - test.beforeEach(t => { let root = document.getElementById("root"); if (!root) { diff --git a/test/01.render-jsx-dom-elements.test.js b/test/01.render-jsx-dom-elements.test.js index 5a01c02..8f4cb1f 100644 --- a/test/01.render-jsx-dom-elements.test.js +++ b/test/01.render-jsx-dom-elements.test.js @@ -1,11 +1,8 @@ import test from "ava"; -import browserEnv from "browser-env"; +import "./_browser-mock"; /** @jsx createElement */ import { render, createElement } from "../src/didact"; -// Create document global var -browserEnv(["document"]); - test.beforeEach(t => { let root = document.getElementById("root"); if (!root) { @@ -25,7 +22,12 @@ test("render jsx div", t => { test("render div with children", t => { const root = t.context.root; - const element =
; + const element = ( +
+ + +
+ ); render(element, root); t.is(root.innerHTML, '
'); }); diff --git a/test/02.re-render-element.test.js b/test/02.re-render-element.test.js index 28ff94a..146b53b 100644 --- a/test/02.re-render-element.test.js +++ b/test/02.re-render-element.test.js @@ -1,11 +1,8 @@ import test from "ava"; -import browserEnv from "browser-env"; +import "./_browser-mock"; /** @jsx createElement */ import { render, createElement } from "../src/didact"; -// Create document global var -browserEnv(["document"]); - test.beforeEach(t => { let root = document.getElementById("root"); if (!root) { diff --git a/test/03.reconciliation.test.js b/test/03.reconciliation.test.js index f6ddb7f..edc13f9 100644 --- a/test/03.reconciliation.test.js +++ b/test/03.reconciliation.test.js @@ -1,11 +1,8 @@ import test from "ava"; -import browserEnv from "browser-env"; +import "./_browser-mock"; /** @jsx createElement */ import { render, createElement } from "../src/didact"; -// Create document global var -browserEnv(["document"]); - test.beforeEach(t => { let root = document.getElementById("root"); if (!root) { diff --git a/test/04.components.test.js b/test/04.components.test.js index 6a540cc..1cdc790 100644 --- a/test/04.components.test.js +++ b/test/04.components.test.js @@ -1,11 +1,8 @@ import test from "ava"; -import browserEnv from "browser-env"; +import "./_browser-mock"; /** @jsx createElement */ import { render, createElement, Component } from "../src/didact"; -// Create document global var -browserEnv(["document"]); - test.beforeEach(t => { let root = document.getElementById("root"); if (!root) { @@ -20,7 +17,12 @@ test("render component", t => { const root = t.context.root; class FooComponent extends Component { render() { - return
; + return ( +
+ + +
+ ); } } render(, root); @@ -31,7 +33,12 @@ test("render component with props", t => { const root = t.context.root; class FooComponent extends Component { render() { - return
{this.props.name}
; + return ( +
+ {this.props.name} + +
+ ); } } render(, root); diff --git a/test/05.set-state.test.js b/test/05.set-state.test.js index 34e1f28..8593fde 100644 --- a/test/05.set-state.test.js +++ b/test/05.set-state.test.js @@ -1,11 +1,8 @@ import test from "ava"; -import browserEnv from "browser-env"; +import "./_browser-mock"; /** @jsx createElement */ import { render, createElement, Component } from "../src/didact"; -// Create document global var -browserEnv(["document"]); - test.beforeEach(t => { let root = document.getElementById("root"); if (!root) { diff --git a/test/_browser-mock.js b/test/_browser-mock.js new file mode 100644 index 0000000..66688a8 --- /dev/null +++ b/test/_browser-mock.js @@ -0,0 +1,11 @@ +import browserEnv from "browser-env"; + +browserEnv(["document"]); + +const deadline = { + timeRemaining: () => 1000 +}; + +global["requestIdleCallback"] = function requestIdleCallback(fn) { + fn(deadline); +};