feat: allow to modify rendered tree

This commit is contained in:
Alexander Navarro 2024-06-26 10:42:18 -04:00
parent 36cb4e667d
commit 1dc675661a
Signed by untrusted user who does not match committer: anavarro
GPG key ID: 6426043E9FA3E3B5
3 changed files with 220 additions and 60 deletions

View file

@ -1,10 +1,16 @@
import { import {
YarEffectTag,
YarElement, YarElement,
YarFiber, YarFiber,
YarHTMLTagName, YarHTMLTagName,
YarProps, YarProps,
} from "./YarJs.interfaces"; } 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 * 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) { function generateDomNode(fiber: YarFiber) {
const dom = const dom =
fiber.type === "TEXT" fiber.type === "TEXT"
? document.createTextNode("") ? document.createTextNode("")
: document.createElement(fiber.type); : document.createElement(fiber.type);
Object.keys(fiber.props) updateDom(dom, {}, 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];
});
return dom; 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<string, unknown>,
nextProps: Record<string, unknown>,
) {
const isEvent = (key: string) => key.startsWith("on");
const isProperty = (key: string) => key !== "children" && !isEvent(key);
const isNew =
(prev: Record<string, unknown>, next: Record<string, unknown>) =>
(key: string) =>
prev[key] !== next[key];
const isGone =
(_prev: Record<string, unknown>, next: Record<string, unknown>) =>
(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) { function commitWork(fiber: YarFiber | null) {
if (!fiber) return; if (!fiber) return;
const domParent = fiber.parent!.dom!; 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.child);
commitWork(fiber.sibling); commitWork(fiber.sibling);
} }
/** Commit the work to be proccesed into the real DOM from it's root */
function commitRoot() { function commitRoot() {
if (!wipRoot) return; if (!WIP_ROOT) return;
commitWork(wipRoot.child); FIBERS_TO_DELETE.forEach(commitWork);
wipRoot = null; 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 { 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) { if (!fiber.dom) {
// Create the actual dom element // Create the actual dom element
fiber.dom = generateDomNode(fiber); fiber.dom = generateDomNode(fiber);
} }
// Create a new fiber for each child // Create a new fiber for each child
const elements = fiber.props.children; reconcileChildren(fiber, 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++;
}
// Search for the new fiber that needs to be processed // Search for the new fiber that needs to be processed
if (fiber.child) { 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 * 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) { function processFiberTree(deadline: IdleDeadline) {
let shouldYield = false; let shouldYield = false;
while (nextUnitOfWork && !shouldYield) { while (FIBER_TO_PROCESS && !shouldYield) {
nextUnitOfWork = processFiber(nextUnitOfWork); FIBER_TO_PROCESS = processFiber(FIBER_TO_PROCESS);
shouldYield = deadline.timeRemaining() < 1; shouldYield = deadline.timeRemaining() < 1;
} }
if (!nextUnitOfWork && wipRoot) { if (!FIBER_TO_PROCESS && WIP_ROOT) {
commitRoot(); commitRoot();
} }
@ -155,24 +294,29 @@ function processFiberTree(deadline: IdleDeadline) {
requestIdleCallback(processFiberTree); 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( export function render(
element: React.JSX.Element, element: React.JSX.Element,
container: HTMLElement, container: HTMLElement,
): void { ): void {
wipRoot = { WIP_ROOT = {
type: "", type: "",
dom: container, dom: container,
parent: null, parent: null,
child: null, child: null,
sibling: null, sibling: null,
alternate: CURRENT_ROOT,
effectTag: null,
props: { props: {
children: [element], children: [element],
}, },
}; };
nextUnitOfWork = wipRoot; FIBERS_TO_DELETE = [];
FIBER_TO_PROCESS = WIP_ROOT;
} }
export function Fragment() {} export function Fragment() {}

View file

@ -1,5 +1,11 @@
export type YarHTMLTagName = keyof React.JSX.IntrinsicElements | string; export type YarHTMLTagName = keyof React.JSX.IntrinsicElements | string;
export enum YarEffectTag {
Update,
Placement,
Deletion,
}
export type YarProps = { export type YarProps = {
children: YarElement[]; children: YarElement[];
[key: string]: unknown; [key: string]: unknown;
@ -15,4 +21,6 @@ export interface YarFiber extends YarElement {
dom: null | HTMLElement | Text; dom: null | HTMLElement | Text;
child: null | YarFiber; child: null | YarFiber;
sibling: null | YarFiber; sibling: null | YarFiber;
alternate?: null | YarFiber;
effectTag: null | YarEffectTag;
} }

View file

@ -3,11 +3,19 @@ import { render } from "./lib/YarJS";
const root = document.querySelector<HTMLDivElement>("#app")!; const root = document.querySelector<HTMLDivElement>("#app")!;
const element = ( const rerender = (value: string) => {
<div id="foo"> const onChange = (e: React.FormEvent<HTMLInputElement>) => {
<a>bar</a> rerender(e.currentTarget.value);
<b /> };
</div>
);
render(element, root); const element = (
<div id="foo">
<input type="text" onInput={onChange} value={value} />
<h2>Hello {value}</h2>
</div>
);
render(element, root);
};
rerender("foo");