From 1dc675661aa3353e1eb94c44f7da3ba5bc722125 Mon Sep 17 00:00:00 2001 From: aleidk Date: Wed, 26 Jun 2024 10:42:18 -0400 Subject: [PATCH] feat: allow to modify rendered tree --- src/lib/YarJS.ts | 250 ++++++++++++++++++++++++++++-------- src/lib/YarJs.interfaces.ts | 8 ++ src/main.tsx | 22 +++- 3 files changed, 220 insertions(+), 60 deletions(-) diff --git a/src/lib/YarJS.ts b/src/lib/YarJS.ts index 184c33d..c2bb31e 100644 --- a/src/lib/YarJS.ts +++ b/src/lib/YarJS.ts @@ -1,10 +1,16 @@ import { + YarEffectTag, YarElement, YarFiber, YarHTMLTagName, YarProps, } from "./YarJs.interfaces"; +let FIBER_TO_PROCESS: YarFiber | null | undefined = null; +let WIP_ROOT: YarFiber | null | undefined = null; +let CURRENT_ROOT: YarFiber | null | undefined = null; +let FIBERS_TO_DELETE: YarFiber[] = []; + /** * This function converts Text into Dom Text Elements */ @@ -37,82 +43,138 @@ export function createElement( }; } +/** + * Dynamically generate a Dom node depending on it's type + * @param fiber - The Fiber Tree element to generate a DOM node + * @returns a new DOM Node + */ function generateDomNode(fiber: YarFiber) { const dom = fiber.type === "TEXT" ? document.createTextNode("") : document.createElement(fiber.type); - Object.keys(fiber.props) - .filter((key) => key !== "children") - .forEach((key) => { - // @ts-expect-error: I cannot figure it out how to properly type the dom props - dom[key] = fiber.props[key]; - }); + updateDom(dom, {}, fiber.props); return dom; } -let nextUnitOfWork: YarFiber | null | undefined = null; -let wipRoot: YarFiber | null | undefined = null; +/** + * Apply a set of props to the DOM element without affecting it's placement + * @param dom - Element to update + * @param prevProps - Previos set of params + * @param nextProps - New set of params + */ +function updateDom( + dom: HTMLElement | Text, + prevProps: Record, + nextProps: Record, +) { + const isEvent = (key: string) => key.startsWith("on"); + const isProperty = (key: string) => key !== "children" && !isEvent(key); + const isNew = + (prev: Record, next: Record) => + (key: string) => + prev[key] !== next[key]; + const isGone = + (_prev: Record, next: Record) => + (key: string) => + !(key in next); + // Remove old properties + Object.keys(prevProps) + .filter(isProperty) + .filter(isGone(prevProps, nextProps)) + .forEach((name) => { + // @ts-expect-error: I cannot figure it out how to properly type the dom props + dom[name] = ""; + }); + + // Set new or changed properties + Object.keys(nextProps) + .filter(isProperty) + .filter(isNew(prevProps, nextProps)) + .forEach((name) => { + // @ts-expect-error: I cannot figure it out how to properly type the dom props + dom[name] = nextProps[name]; + }); + + //Remove old or changed event listeners + 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] as EventListenerOrEventListenerObject, + ); + }); + + // Add event listeners + Object.keys(nextProps) + .filter(isEvent) + .filter(isNew(prevProps, nextProps)) + .forEach((name) => { + const eventType = name.toLowerCase().substring(2); + dom.addEventListener( + eventType, + nextProps[name] as EventListenerOrEventListenerObject, + ); + }); +} + +/** + * Actually commit the processed Fiber to the real DOM + * @param fiber - The Fiber Element to process + */ function commitWork(fiber: YarFiber | null) { if (!fiber) return; const domParent = fiber.parent!.dom!; - domParent.appendChild(fiber.dom!); + + switch (fiber.effectTag) { + case YarEffectTag.Placement: + domParent.appendChild(fiber.dom!); + break; + case YarEffectTag.Deletion: + domParent.removeChild(fiber.dom!); + break; + case YarEffectTag.Update: + updateDom(fiber.dom!, fiber.alternate!.props, fiber.props); + break; + } + commitWork(fiber.child); commitWork(fiber.sibling); } +/** Commit the work to be proccesed into the real DOM from it's root */ function commitRoot() { - if (!wipRoot) return; + if (!WIP_ROOT) return; - commitWork(wipRoot.child); - wipRoot = null; + FIBERS_TO_DELETE.forEach(commitWork); + commitWork(WIP_ROOT.child); + CURRENT_ROOT = WIP_ROOT; + WIP_ROOT = null; } +/** + * Process the Fiber Tree, this is a representation of the DOM + * Since this process could be interrupted before the whole + * tree is processed, we just do the representation and computation here, + * then in "commitWork" we add the representation to the actual DOM + * + * @param fiber - The Fiber to process + */ function processFiber(fiber: YarFiber): YarFiber | undefined { - // Process the Fiber Tree, this is a representation of the DOM - // Since this this process could be interrupted before the whole - // tree is processed, we just do the representation and computation here, - // then in "commitWork" we add the representation to the actual DOM - if (!fiber.dom) { // Create the actual dom element fiber.dom = generateDomNode(fiber); } // Create a new fiber for each child - const elements = fiber.props.children; - let index = 0; - let prevSibling: YarFiber | null = null; - - while (index < elements.length) { - const element = elements[index]; - - const newFiber = { - parent: fiber, - type: element.type, - props: element.props, - child: null, - sibling: null, - dom: null, - }; - - // Each Fiber only holds a reference to it's first child (in the Fiber Tree representation), - // so if is the first new Fiber we add it as a child to the parent, - // if is not, we add as a sibling of the last child - // Nonetheless, each fiber has a reference to it's parent - if (index === 0) { - fiber.child = newFiber; - } else if (prevSibling !== null) { - prevSibling.sibling = newFiber; - } - - prevSibling = newFiber; - index++; - } + reconcileChildren(fiber, fiber.props.children); // Search for the new fiber that needs to be processed if (fiber.child) { @@ -135,17 +197,94 @@ function processFiber(fiber: YarFiber): YarFiber | undefined { } /** + * Process the children of the Fiber node, deciding for each child + * if it needs to be updated, added or deleted based on the last render tree. + * @param fiber - Fiber element to process + * @param elements - Children to add + */ +function reconcileChildren(fiber: YarFiber, elements: YarElement[]) { + let index = 0; + let prevSibling: YarFiber | null = null; + let oldFiber = fiber.alternate?.child; // old element rendered + let newFiber: YarFiber | null = null; + + while (index < elements.length || oldFiber) { + const element = elements[index]; // new element to render + + // NOTE: Here React also uses keys, that makes a better reconciliation. + // For example, it detects when children change places in the element array. + + const isSameType = oldFiber?.type === element.type; + + if (isSameType) { + // update the node + newFiber = { + parent: fiber, + type: oldFiber!.type, + props: element.props, + alternate: oldFiber, + dom: oldFiber!.dom, + child: null, + sibling: null, + effectTag: YarEffectTag.Update, + }; + } + + if (!isSameType && element !== null && element !== undefined) { + // add new node + newFiber = { + parent: fiber, + type: element.type, + props: element.props, + child: null, + sibling: null, + dom: null, + alternate: null, + effectTag: YarEffectTag.Placement, + }; + } + + if (!isSameType && oldFiber !== null && oldFiber !== undefined) { + // Delete old node + oldFiber!.effectTag = YarEffectTag.Deletion; + FIBERS_TO_DELETE.push(oldFiber!); + } + + if (oldFiber) { + oldFiber = oldFiber.sibling; + } + + // Each Fiber only holds a reference to it's first child (in the Fiber Tree representation), + // so if is the first new Fiber we add it as a child to the parent, + // if is not, we add as a sibling of the last child + // Nonetheless, each fiber has a reference to it's parent + if (index === 0) { + fiber.child = newFiber; + } else if (prevSibling !== null) { + prevSibling.sibling = newFiber; + } + + prevSibling = newFiber; + index++; + } +} + +/** + * Start processing the fiber tree in the background while it has work to do. + * * This function should be passed on requestIdleCallback - * so the browser can process it while idle + * so the browser can process it while idle. + * + * @param deadline - IdleDeadline procided by the browser */ function processFiberTree(deadline: IdleDeadline) { let shouldYield = false; - while (nextUnitOfWork && !shouldYield) { - nextUnitOfWork = processFiber(nextUnitOfWork); + while (FIBER_TO_PROCESS && !shouldYield) { + FIBER_TO_PROCESS = processFiber(FIBER_TO_PROCESS); shouldYield = deadline.timeRemaining() < 1; } - if (!nextUnitOfWork && wipRoot) { + if (!FIBER_TO_PROCESS && WIP_ROOT) { commitRoot(); } @@ -155,24 +294,29 @@ function processFiberTree(deadline: IdleDeadline) { requestIdleCallback(processFiberTree); /** - * Add a new element to render into the DOM + * Render an element into a container. + * @param element - New element to render + * @param container - The parent DOM to add the element into */ export function render( element: React.JSX.Element, container: HTMLElement, ): void { - wipRoot = { + WIP_ROOT = { type: "", dom: container, parent: null, child: null, sibling: null, + alternate: CURRENT_ROOT, + effectTag: null, props: { children: [element], }, }; - nextUnitOfWork = wipRoot; + FIBERS_TO_DELETE = []; + FIBER_TO_PROCESS = WIP_ROOT; } export function Fragment() {} diff --git a/src/lib/YarJs.interfaces.ts b/src/lib/YarJs.interfaces.ts index 974c502..a9ded1d 100644 --- a/src/lib/YarJs.interfaces.ts +++ b/src/lib/YarJs.interfaces.ts @@ -1,5 +1,11 @@ export type YarHTMLTagName = keyof React.JSX.IntrinsicElements | string; +export enum YarEffectTag { + Update, + Placement, + Deletion, +} + export type YarProps = { children: YarElement[]; [key: string]: unknown; @@ -15,4 +21,6 @@ export interface YarFiber extends YarElement { dom: null | HTMLElement | Text; child: null | YarFiber; sibling: null | YarFiber; + alternate?: null | YarFiber; + effectTag: null | YarEffectTag; } diff --git a/src/main.tsx b/src/main.tsx index 0a7e63d..8b2392e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,11 +3,19 @@ import { render } from "./lib/YarJS"; const root = document.querySelector("#app")!; -const element = ( -
- bar - -
-); +const rerender = (value: string) => { + const onChange = (e: React.FormEvent) => { + rerender(e.currentTarget.value); + }; -render(element, root); + const element = ( +
+ +

Hello {value}

+
+ ); + + render(element, root); +}; + +rerender("foo");