From f4427f7b3fe2a912518098c00db69b3d82f6d750 Mon Sep 17 00:00:00 2001 From: MasterGordon Date: Fri, 29 Aug 2025 00:43:54 +0200 Subject: [PATCH] improved cr by very basic placement commit --- examples/client-renderer/index.html | 12 + examples/client-renderer/src/cr.ts | 302 +++++++++++++++++- examples/client-renderer/src/jsx.d.ts | 158 +++++++++ examples/client-renderer/src/main.tsx | 21 ++ .../src/runtime/jsx-dev-runtime.ts | 3 + .../src/runtime/jsx-runtime.ts | 28 ++ examples/client-renderer/src/symbols.ts | 7 + examples/client-renderer/src/types.ts | 95 ++++++ examples/client-renderer/tsconfig.json | 6 +- 9 files changed, 616 insertions(+), 16 deletions(-) create mode 100644 examples/client-renderer/src/jsx.d.ts create mode 100644 examples/client-renderer/src/runtime/jsx-dev-runtime.ts create mode 100644 examples/client-renderer/src/runtime/jsx-runtime.ts create mode 100644 examples/client-renderer/src/symbols.ts create mode 100644 examples/client-renderer/src/types.ts diff --git a/examples/client-renderer/index.html b/examples/client-renderer/index.html index 2f1349e..2f63fde 100644 --- a/examples/client-renderer/index.html +++ b/examples/client-renderer/index.html @@ -10,6 +10,18 @@
+ diff --git a/examples/client-renderer/src/cr.ts b/examples/client-renderer/src/cr.ts index 95206b4..9a40d95 100644 --- a/examples/client-renderer/src/cr.ts +++ b/examples/client-renderer/src/cr.ts @@ -1,19 +1,44 @@ -interface FiberNode { - child?: FiberNode; - sibling?: FiberNode; - parent?: FiberNode; -} +import { domFiber, ElementSymbol, FiberNodeSymbol } from "./symbols"; +import { + EffectTag, + isFunctionFiber, + isHostFiber, + type Child, + type FiberNode, + type FunctionFiber, + type HostFiber, + type JSXElement, + type RootFiber, +} from "./types"; let unitOfWork: FiberNode | null = null; +let wipFiber: FiberNode; +let hookIndex: number; +let effectFiber: FiberNode | null = null; +let wipRoot: RootFiber | null = null; + +const textElementType = "$$TEXT_ELEMENT$$"; +const createTextElement = (text: string): JSXElement => ({ + $$typeof: ElementSymbol, + type: textElementType, + props: { + nodeValue: text, + children: [], + }, +}); function performUnitOfWork(fiber: FiberNode) { - console.log("performUnitOfWork", fiber); + if (isFunctionFiber(fiber)) { + updateFunctionComponent(fiber); + } else if (isHostFiber(fiber)) { + updateHostComponent(fiber); + } if (fiber.child) { return fiber.child; } - let nextFiber: FiberNode | undefined = fiber; + let nextFiber: FiberNode | RootFiber | null = fiber; while (nextFiber) { if (nextFiber.sibling) { return nextFiber.sibling; @@ -31,19 +56,266 @@ function workLoop(deadline: IdleDeadline) { if (unitOfWork) { requestIdleCallback(workLoop); - } else { - // Commit all the changes that have accumulated. + } else if (wipRoot) { + commitRoot(); } } -function linkParents(fiber: FiberNode, parent?: FiberNode) { - fiber.parent = parent; - if (fiber.child) { - linkParents(fiber.child, fiber); +function createFiberFromElement(element: JSXElement): FiberNode { + return { + $$typeof: FiberNodeSymbol, + type: element.type, + key: element.key ?? null, + child: null, + sibling: null, + parent: null, + index: 0, + dom: null, + hooks: [], + pendingProps: element.props, + effectTag: EffectTag.NoEffect, + alternate: null, + nextEffect: null, + }; +} + +function updateFunctionComponent(fiber: FunctionFiber) { + wipFiber = fiber; + hookIndex = 0; + wipFiber.hooks = []; + wipFiber.effectTag = EffectTag.NoEffect; + const children = [fiber.type(fiber.pendingProps)]; + reconcileChildren(fiber, children); +} + +function updateHostComponent(fiber: HostFiber) { + wipFiber = fiber; + reconcileChildren(fiber, fiber.pendingProps.children); +} + +function pushEffect(fiber: FiberNode) { + if (!wipRoot) { + throw "pushEffect() must be called inside a root"; } - if (fiber.sibling) { - linkParents(fiber.sibling, fiber); + if (effectFiber) { + effectFiber.nextEffect = fiber; + effectFiber = fiber; + } else { + effectFiber = fiber; + // @ts-ignore // HACK: Only for debugging + window.effectFiber = fiber; + unitOfWork = fiber; + } + if (!wipRoot.firstEffect) { + wipRoot.firstEffect = fiber; + } + wipRoot.lastEffect = fiber; +} + +function reconcileChildren(wipFiber: FiberNode, children: Child[] = []) { + let index = 0; + let oldFiber = wipFiber.alternate?.child; + let prevSibling: FiberNode | null = null; + const elements = children + .filter((child) => child !== null && child !== undefined) + .map((child) => { + if (typeof child === "string") { + return createTextElement(child); + } + return child; + }); + + while (index < elements.length || oldFiber) { + const element = elements[index]; + let newFiber: FiberNode | null = null; + + const isSameType = oldFiber && element && element.type === oldFiber.type; + if (isSameType && oldFiber) { + newFiber = { + $$typeof: FiberNodeSymbol, + type: oldFiber.type, + key: element.key ?? null, + child: null, + sibling: null, + parent: wipFiber, + index: 0, + dom: oldFiber.dom, + hooks: [], + pendingProps: element.props, + effectTag: EffectTag.Update, + alternate: oldFiber, + nextEffect: null, + }; + pushEffect(newFiber); + } + + if (element && !isSameType) { + newFiber = { + $$typeof: FiberNodeSymbol, + type: element.type, + key: element.key ?? null, + child: null, + sibling: null, + parent: wipFiber, + index: 0, + dom: null, + hooks: [], + pendingProps: element.props, + effectTag: EffectTag.Placement, + alternate: null, + nextEffect: null, + }; + pushEffect(newFiber); + } + + if (oldFiber && !isSameType) { + oldFiber.effectTag = EffectTag.Deletion; + pushEffect(oldFiber); + } + + if (oldFiber) { + oldFiber = oldFiber.sibling; + } + + if (index === 0) { + wipFiber.child = newFiber; + } else if (element && prevSibling) { + prevSibling.sibling = newFiber; + } + + prevSibling = newFiber; + index++; } } requestIdleCallback(workLoop); + +// TODO: Maybe store next parent HostFiber +function findDomParent(fiber: FiberNode) { + let parent = fiber.parent; + while (parent) { + if (parent.dom) { + return parent; + } + parent = parent.parent; + } + return null; +} + +function commitRoot() { + if (!wipRoot) return; + effectFiber = wipRoot.firstEffect; + while (effectFiber) { + // Do effect + if (effectFiber.effectTag & EffectTag.Placement) { + let dom: Node; + if (effectFiber.type === textElementType) { + dom = document.createTextNode(effectFiber.pendingProps.nodeValue); + } else { + dom = document.createElement(effectFiber.type as string); + // TODO: Set props + // TODO: Maybe add event section + // TODO: Maybe add ref in far future might consider a general on mount and on unmount event + } + effectFiber.dom = dom; + const parent = findDomParent(effectFiber); + if (parent?.dom) { + console.log("Appending", dom, "into", parent.dom); + parent.dom.appendChild(dom); + } else { + throw "Can't commit without parent"; + } + } + effectFiber = effectFiber.nextEffect; + } + wipRoot = null; +} + +const events: (keyof GlobalEventHandlersEventMap)[] = [ + "click", + "mousemove", + "mouseenter", + "mouseleave", + "mouseover", + "mouseout", + "mousedown", + "mouseup", + "pointermove", + "pointerenter", + "pointerleave", + "pointerover", + "pointerout", + "blur", + "focus", + "drag", + "dragend", + "dragenter", + "dragleave", + "dragover", + "dragstart", + "drop", + "touchcancel", + "touchend", + "touchmove", + "touchstart", +]; +const getEventProp = (eventType: string) => { + return `on${eventType.substring(0, 1).toUpperCase()}${eventType.substring(1)}`; +}; +function handleEvent(event: Event, root: RootFiber) { + const target = event.target as HTMLElement; + const type = event.type; + let node: FiberNode | RootFiber | null = target[domFiber] ?? null; + let propagationStopped = false; + const ogStopPropagation = event.stopPropagation; + event.stopPropagation = () => { + propagationStopped = true; + ogStopPropagation.call(event); + }; + do { + node?.pendingProps[getEventProp(type)]?.(event); + if (propagationStopped) { + break; + } + if (event.bubbles) { + node = node?.parent ?? null; + } + } while (node?.dom !== root.dom); +} + +export function createRootFiber(root: HTMLElement): RootFiber { + const rootFiber: RootFiber = { + $$typeof: FiberNodeSymbol, + type: undefined, + key: null, + child: null, + sibling: null, + parent: null, + index: 0, + dom: root, + hooks: [], + pendingProps: null, + effectTag: EffectTag.NoEffect, + alternate: null, + nextEffect: null, + isRoot: true, + firstEffect: null, + lastEffect: null, + }; + for (const event of events) { + rootFiber.dom.addEventListener(event, (event) => + handleEvent(event, rootFiber), + ); + } + return rootFiber; +} + +export function render(rootFiber: RootFiber, element: JSXElement) { + wipRoot = rootFiber; + const fiber = createFiberFromElement(element); + fiber.parent = rootFiber; + rootFiber.child = fiber; + wipRoot.child = fiber; + unitOfWork = fiber; + console.log(fiber); +} diff --git a/examples/client-renderer/src/jsx.d.ts b/examples/client-renderer/src/jsx.d.ts new file mode 100644 index 0000000..6cc6f95 --- /dev/null +++ b/examples/client-renderer/src/jsx.d.ts @@ -0,0 +1,158 @@ +namespace JSX { + interface FC { + (props: any): JSX.Element | string | null; + } + type Children = + | JSX.Element + | (string | JSX.Element | JSX.Element[])[] + | string + | null + | undefined; + type BaseElementPropsWithoutChildren = { + className?: string; + id?: string; + tabindex?: number | string; + style?: import("./types").Styles; + [`data-${string}`]?: string | boolean; + onClick?: (event: MouseEvent) => void; + } & Partial; + type BaseElementProps = BaseElementPropsWithoutChildren & { + children?: JSX.Children; + }; + type SvgProps = BaseElementProps & { + ["xml:lang"]?: string; + ["xml:space"]?: string; + xmlns?: string; + // XLink attributes + ["xlink:hrefDeprecated"]?: string; + ["xlink:type"]?: string; + ["xlink:role"]?: string; + ["xlink:arcrole"]?: string; + ["xlink:title"]?: string; + ["xlink:show"]?: string; + ["xlink:actuate"]?: string; + // Presentation attributes + ["alignment-baseline"]?: string; + ["baseline-shift"]?: string; + ["clip"]?: string; + ["clipPath"]?: string; + ["clipRule"]?: string; + ["color"]?: string; + ["colorInterpolation"]?: string; + ["colorInterpolationFilters"]?: string; + ["cursor"]?: string; + ["cx"]?: string; + ["cy"]?: string; + ["d"]?: string; + ["direction"]?: string; + ["display"]?: string; + ["dominantBaseline"]?: string; + ["fill"]?: string; + ["fillOpacity"]?: string; + ["fillRule"]?: string; + ["filter"]?: string; + ["floodColor"]?: string; + ["floodOpacity"]?: string; + ["fontFamily"]?: string; + ["fontSize"]?: string; + ["fontSize-adjust"]?: string; + ["fontStretch"]?: string; + ["fontStyle"]?: string; + ["fontVariant"]?: string; + ["fontWeight"]?: string; + ["glyphOrientation-horizontal"]?: string; + ["glyphOrientation-vertical"]?: string; + ["height"]?: string; + ["imageRendering"]?: string; + ["letterSpacing"]?: string; + ["lightingColor"]?: string; + ["markerEnd"]?: string; + ["markerMid"]?: string; + ["markerStart"]?: string; + ["mask"]?: string; + ["maskType"]?: string; + ["opacity"]?: string; + ["overflow"]?: string; + ["pointerEvents"]?: string; + ["r"]?: string; + ["rx"]?: string; + ["ry"]?: string; + ["shapeRendering"]?: string; + ["stopColor"]?: string; + ["stopOpacity"]?: string; + ["stroke"]?: string; + ["strokeDasharray"]?: string; + ["strokeDashoffset"]?: string; + ["strokeLinecap"]?: string; + ["strokeLinejoin"]?: string; + ["strokeMiterlimit"]?: string; + ["strokeOpacity"]?: string; + ["strokeWidth"]?: string; + ["textAnchor"]?: string; + ["textDecoration"]?: string; + ["textOverflow"]?: string; + ["textRendering"]?: string; + ["transform"]?: string; + ["transformOrigin"]?: string; + ["unicodeBidi"]?: string; + ["vectorEffect"]?: string; + ["visibility"]?: string; + ["whiteSpace"]?: string; + ["width"]?: string; + ["wordSpacing"]?: string; + ["writingMode"]?: string; + ["x"]?: string; + ["y"]?: string; + viewbox?: string; + }; + interface IntrinsicElements { + a: BaseElementProps & { + href?: string; + target?: HTMLAnchorElement["target"]; + }; + img: BaseElementProps & { + src: string; + alt?: string; + width?: number; + height?: number; + }; + + // Void IntrinsicElements + // https://github.com/wooorm/html-void-elements + area: BaseElementPropsWithoutChildren; + base: BaseElementPropsWithoutChildren; + basefont: BaseElementPropsWithoutChildren; + bgsound: BaseElementPropsWithoutChildren; + br: BaseElementPropsWithoutChildren; + col: BaseElementPropsWithoutChildren; + command: BaseElementPropsWithoutChildren; + embed: BaseElementPropsWithoutChildren; + frame: BaseElementPropsWithoutChildren; + hr: BaseElementPropsWithoutChildren; + image: BaseElementPropsWithoutChildren; + img: BaseElementPropsWithoutChildren; + input: BaseElementPropsWithoutChildren; + keygen: BaseElementPropsWithoutChildren; + link: BaseElementPropsWithoutChildren; + meta: BaseElementPropsWithoutChildren; + param: BaseElementPropsWithoutChildren; + source: BaseElementPropsWithoutChildren; + track: BaseElementPropsWithoutChildren; + wbr: BaseElementPropsWithoutChildren; + svg: SvgProps; + path: SvgProps; + g: SvgProps; + [tagName: string]: BaseElementProps; + } + interface Element { + $$typeof: symbol; + type: string | FC | null; + key?: string; + ref?: string; + children?: JSX.Children; + props: any; + } + interface ElementChildrenAttribute { + children: {}; + } +} diff --git a/examples/client-renderer/src/main.tsx b/examples/client-renderer/src/main.tsx index e69de29..2cb2026 100644 --- a/examples/client-renderer/src/main.tsx +++ b/examples/client-renderer/src/main.tsx @@ -0,0 +1,21 @@ +import { createRootFiber, render } from "./cr"; + +const App = () => { + const items = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + + return ( +
+
Hello world
+
    + {items.map((item) => ( +
  • {String(item)}
  • + ))} +
+
+ ); +}; + +console.log("foo"); +const root = createRootFiber(document.getElementById("app")!); +console.log(
Hello World
); +render(root, ); diff --git a/examples/client-renderer/src/runtime/jsx-dev-runtime.ts b/examples/client-renderer/src/runtime/jsx-dev-runtime.ts new file mode 100644 index 0000000..1e39ca3 --- /dev/null +++ b/examples/client-renderer/src/runtime/jsx-dev-runtime.ts @@ -0,0 +1,3 @@ +import { jsx, Fragment } from "./jsx-runtime"; +export const jsxDEV = jsx; +export { jsx, Fragment }; diff --git a/examples/client-renderer/src/runtime/jsx-runtime.ts b/examples/client-renderer/src/runtime/jsx-runtime.ts new file mode 100644 index 0000000..e925f40 --- /dev/null +++ b/examples/client-renderer/src/runtime/jsx-runtime.ts @@ -0,0 +1,28 @@ +import { ElementSymbol } from "~/symbols"; +import type { FC, JSXElement } from "~/types"; + +export const jsx = function (type: string | FC, fullProps: any): JSXElement { + const { key, children, ref, ...props } = fullProps; + return { + $$typeof: ElementSymbol, + type, + props: { + ...props, + children: Array.isArray(children) ? children : [children], + }, + key: key, + ref: ref, + }; +}; + +export const Fragment = function (fullProps: any): JSXElement { + const { children, ...props } = fullProps; + return { + $$typeof: ElementSymbol, + type: null, + props, + key: props.key, + ref: props.ref, + children, + }; +}; diff --git a/examples/client-renderer/src/symbols.ts b/examples/client-renderer/src/symbols.ts new file mode 100644 index 0000000..601e8f2 --- /dev/null +++ b/examples/client-renderer/src/symbols.ts @@ -0,0 +1,7 @@ +export const ElementSymbol = Symbol.for("cr.element"); +export const FragmentSymbol = Symbol.for("cr.fragment"); +export const FiberNodeSymbol = Symbol.for("cr.fiber-node"); +export type DomFiberKey = "__cr_fiber"; +export type DomPropsKey = "__cr_props"; +export const domFiber = "__cr_fiber" satisfies DomFiberKey; +export const domProps = "__cr_props" satisfies DomPropsKey; diff --git a/examples/client-renderer/src/types.ts b/examples/client-renderer/src/types.ts new file mode 100644 index 0000000..ca4a576 --- /dev/null +++ b/examples/client-renderer/src/types.ts @@ -0,0 +1,95 @@ +import { domProps, ElementSymbol, FiberNodeSymbol, domFiber } from "./symbols"; + +export type FC = (props: any) => JSXElement | string | null; + +export type Child = JSXElement | string | null | undefined; +export type Children = Child | Child[]; +export type ElementType = string | FC | null; + +export interface JSXElement { + $$typeof: typeof ElementSymbol; + type: ElementType; + key?: string; + ref?: string; + props: any; + children?: Children; +} + +export enum EffectTag { + NoEffect = 0x0, + Placement = 0x1, + Update = 0x2, + Deletion = 0x4, + Hydration = 0x8, + Passive = 0x10, + Layout = 0x20, +} + +export const StateHook = Symbol.for("cr.state-hook"); +export const EffectHook = Symbol.for("cr.effect-hook"); +type Hook = + | { + type: typeof StateHook; + } + | { + type: typeof EffectHook; + }; + +export interface FiberNode { + $$typeof: typeof FiberNodeSymbol; + // Identity + type: ElementType; + key: string | null; + isRoot?: boolean; + + child: FiberNode | null; + sibling: FiberNode | null; + parent: FiberNode | RootFiber | null; + index: number; + + // State / Instance + dom: Node | null; + hooks: Hook[]; + pendingProps: any; + + effectTag: EffectTag; + + // Used for double buffering + alternate: FiberNode | null; + nextEffect: FiberNode | null; +} + +export type FunctionFiber = FiberNode & { + type: FC; +}; + +export type HostFiber = FiberNode & { + type: string; +}; + +export type RootFiber = Omit & { + type: undefined; + dom: HTMLElement; + isRoot: true; + firstEffect: FiberNode | null; + lastEffect: FiberNode | null; +}; + +export const isFiberNode = (fiber: any): fiber is FiberNode => + fiber && fiber.$$typeof === FiberNodeSymbol; + +export const isElement = (fiber: any): fiber is JSXElement => + fiber && fiber.$$typeof === ElementSymbol; + +export const isFunctionFiber = (fiber: any): fiber is FunctionFiber => + fiber.type instanceof Function; + +export const isHostFiber = (fiber: any): fiber is HostFiber => + typeof fiber.type === "string"; + +declare global { + interface HTMLElement { + [domFiber]?: FiberNode; + [domProps]?: any; + } +} diff --git a/examples/client-renderer/tsconfig.json b/examples/client-renderer/tsconfig.json index be3d138..43f6dcf 100644 --- a/examples/client-renderer/tsconfig.json +++ b/examples/client-renderer/tsconfig.json @@ -6,6 +6,7 @@ "module": "Preserve", "moduleDetection": "force", "jsx": "react-jsx", + "jsxImportSource": "~/runtime", "allowJs": true, // Bundler mode @@ -24,6 +25,9 @@ // Some stricter flags (disabled by default) "noUnusedLocals": false, "noUnusedParameters": false, - "noPropertyAccessFromIndexSignature": false + "noPropertyAccessFromIndexSignature": false, + "paths": { + "~/*": ["./src/*"] + } } }