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 {
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<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) {
if (!fiber) return;
const domParent = fiber.parent!.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() {}

View file

@ -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;
}

View file

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