From 8faa9fc4aacc4481d18e4cc40b185be5b7704091 Mon Sep 17 00:00:00 2001 From: "glm-5.1" Date: Mon, 18 May 2026 16:49:22 +0000 Subject: [PATCH] Make Root.render() re-renderable with positional props reconciliation First render still does a full mount and builds fiber tree. Subsequent renders reconcile the new UNode tree against existing fibers: compare props via prepareUpdate, apply changes via commitUpdate. Excess old children remain (structural changes deferred to Phase 2). Function components remain transparent during reconciliation. --- src/host/config.ts | 137 ++++++++++++++++++++--- test/render-rerenderable.test.ts | 185 +++++++++++++++++++++++++++++++ 2 files changed, 306 insertions(+), 16 deletions(-) create mode 100644 test/render-rerenderable.test.ts diff --git a/src/host/config.ts b/src/host/config.ts index af58b1f..43dd2bc 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -40,6 +40,11 @@ export interface Root { unmount(): void; } +function resolveChildren(node: UNode): UNode[] { + if (isURoot(node)) return (node as URoot).children; + return [node]; +} + export function createRoot( host: HostConfig, container: unknown, @@ -97,7 +102,7 @@ export function createRoot( const fiber: Fiber = { instance: inst, tag, - props: el.props as Record, + props: { ...el.props } as Record, key: el.key, children: [], parent: parentFiber, @@ -115,6 +120,95 @@ export function createRoot( return fiber; } + function reconcileNode(fiber: Fiber, node: UNode): void { + if (node == null || node === false) return; + + if (isUPrimitive(node)) { + const nextText = node === null ? "" : String(node); + if (fiber.tag === "#text") { + const prevProps = { ...fiber.props }; + const nextProps = { text: nextText }; + const payload = host.prepareUpdate?.(fiber.instance, fiber.tag as TTag, prevProps, nextProps, ctx); + if (payload !== null && payload !== undefined) { + fiber.prevProps = prevProps; + fiber.props = nextProps; + fiber.effect = { type: "update", payload }; + } + } + return; + } + + if (isURoot(node)) { + const newChildren = (node as URoot).children; + const count = Math.min(fiber.children.length, newChildren.length); + for (let i = 0; i < count; i++) { + reconcileNode(fiber.children[i]!, newChildren[i]!); + } + return; + } + + const el = node as UElement; + + if (typeof el.type === "function") { + const component = el.type as ComponentFn; + const out = component({ ...el.props, children: el.children }); + reconcileNode(fiber, out); + return; + } + + if (fiber.tag !== el.type) return; + + const tag = el.type as TTag; + const prevProps = { ...fiber.props }; + const nextProps = { ...el.props } as Record; + const payload = host.prepareUpdate?.(fiber.instance, tag, prevProps, nextProps, ctx); + + if (payload !== null && payload !== undefined) { + fiber.prevProps = prevProps; + fiber.props = nextProps; + fiber.effect = { type: "update", payload }; + } + + if (el.children.length > 0 || fiber.children.length > 0) { + const elChildren = flattenChildren(el.children); + const count = Math.min(fiber.children.length, elChildren.length); + for (let i = 0; i < count; i++) { + reconcileNode(fiber.children[i]!, elChildren[i]!); + } + } + } + + function flattenChildren(nodes: UNode[]): UNode[] { + const result: UNode[] = []; + for (const n of nodes) { + if (n == null || n === false) continue; + if (Array.isArray(n)) { + result.push(...flattenChildren(n as UNode[])); + } else { + result.push(n); + } + } + return result; + } + + function commitEffects(fiber: Fiber): void { + if (fiber.effect?.type === "update") { + host.commitUpdate?.( + fiber.instance, + fiber.effect.payload, + fiber.tag as TTag, + fiber.prevProps!, + fiber.props, + ctx, + ); + } + fiber.effect = null; + fiber.prevProps = null; + for (const child of fiber.children) { + commitEffects(child); + } + } + return { host, ctx, @@ -122,22 +216,33 @@ export function createRoot( context: rootContext, rootFiber: null, render(node: UNode) { - const root: Fiber = { - instance: undefined as unknown as Instance, - tag: "#root", - props: {}, - key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, - }; - const payloadChildren = isURoot(node) ? (node as URoot).children : [node]; - for (const child of payloadChildren) { - mountNode(child, root); + const payloadChildren = resolveChildren(node); + + if (this.rootFiber === null) { + const root: Fiber = { + instance: undefined as unknown as Instance, + tag: "#root", + props: {}, + key: undefined, + children: [], + parent: null, + effect: null, + signalDisposers: [], + prevProps: null, + }; + for (const child of payloadChildren) { + mountNode(child, root); + } + this.rootFiber = root; + } else { + const rootFiber = this.rootFiber; + const count = Math.min(rootFiber.children.length, payloadChildren.length); + for (let i = 0; i < count; i++) { + reconcileNode(rootFiber.children[i]!, payloadChildren[i]!); + } + commitEffects(rootFiber); } - this.rootFiber = root; + host.finalizeRoot?.(ctx); host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length }); }, diff --git a/test/render-rerenderable.test.ts b/test/render-rerenderable.test.ts new file mode 100644 index 0000000..4503a2d --- /dev/null +++ b/test/render-rerenderable.test.ts @@ -0,0 +1,185 @@ +import { describe, it, expect } from "vitest"; +import { h, createComponent } from "../src/core/h.js"; +import { createRoot as createHostRoot } from "../src/host/config.js"; +import type { HostConfig } from "../src/host/config.js"; + +function makeHost(): { + host: HostConfig>; + instances: { tag: string; props: Record }[]; + texts: string[]; + appends: { parent: string; child: string }[]; + prepareUpdateCalls: { instance: string; tag: string; prevProps: Record; nextProps: Record }[]; + commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record; nextProps: Record }[]; +} { + const instances: { tag: string; props: Record }[] = []; + const texts: string[] = []; + const appends: { parent: string; child: string }[] = []; + const prepareUpdateCalls: { instance: string; tag: string; prevProps: Record; nextProps: Record }[] = []; + const commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record; nextProps: Record }[] = []; + + const host: HostConfig> = { + name: "test", + createRootContext: () => ({}), + createInstance: (tag, props) => { + const id = `${tag}_${instances.length}`; + instances.push({ tag, props }); + return id; + }, + createTextInstance: (text) => { + texts.push(text); + return text; + }, + appendChild: (parent, child) => { + appends.push({ parent, child }); + }, + prepareUpdate: (instance, tag, prevProps, nextProps) => { + prepareUpdateCalls.push({ instance, tag, prevProps, nextProps }); + const changed = Object.keys(nextProps).some( + (k) => prevProps[k] !== nextProps[k], + ) || Object.keys(prevProps).some( + (k) => !(k in nextProps), + ); + return changed ? { prevProps, nextProps } : null; + }, + commitUpdate: (instance, payload, tag, prevProps, nextProps) => { + commitUpdateCalls.push({ instance, payload, tag, prevProps, nextProps }); + }, + }; + return { host, instances, texts, appends, prepareUpdateCalls, commitUpdateCalls }; +} + +describe("Root.render() re-renderable", () => { + it("first render mounts and builds fiber tree", () => { + const { host, instances } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { class: "a" }, "hello")); + + expect(root.rootFiber).not.toBeNull(); + expect(instances.length).toBe(1); + expect(instances[0]!.tag).toBe("div"); + }); + + it("second render does not create duplicate instances", () => { + const { host, instances } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { class: "a" }, "hello")); + const countAfterFirst = instances.length; + + root.render(h("div", { class: "b" }, "hello")); + expect(instances.length).toBe(countAfterFirst); + }); + + it("second render produces one instance tree with updated props", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { class: "a" }, "hello")); + + const divFiber = root.rootFiber!.children[0]!; + expect(divFiber.props.class).toBe("a"); + + root.render(h("div", { class: "b" }, "hello")); + expect(divFiber.props.class).toBe("b"); + }); + + it("prepareUpdate is called for changed props on re-render", () => { + const { host, prepareUpdateCalls } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { class: "old" }, "hello")); + prepareUpdateCalls.length = 0; + + root.render(h("div", { class: "new" }, "hello")); + expect(prepareUpdateCalls.length).toBeGreaterThanOrEqual(1); + const divCall = prepareUpdateCalls.find((c) => c.tag === "div"); + expect(divCall).toBeDefined(); + expect(divCall!.prevProps.class).toBe("old"); + expect(divCall!.nextProps.class).toBe("new"); + }); + + it("commitUpdate is called for changed props on re-render", () => { + const { host, commitUpdateCalls } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { class: "old" }, "hello")); + commitUpdateCalls.length = 0; + + root.render(h("div", { class: "new" }, "hello")); + expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1); + const divCall = commitUpdateCalls.find((c) => c.tag === "div"); + expect(divCall).toBeDefined(); + }); + + it("no commitUpdate when props are unchanged", () => { + const { host, commitUpdateCalls } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { class: "same" }, "hello")); + commitUpdateCalls.length = 0; + + root.render(h("div", { class: "same" }, "hello")); + const divCall = commitUpdateCalls.find((c) => c.tag === "div"); + expect(divCall).toBeUndefined(); + }); + + it("positional children matching: Nth old child matches Nth new child", () => { + const { host, commitUpdateCalls } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", null, h("span", { color: "red" }), h("em", { size: "big" }))); + commitUpdateCalls.length = 0; + + root.render(h("div", null, h("span", { color: "blue" }), h("em", { size: "small" }))); + const spanCall = commitUpdateCalls.find((c) => c.tag === "span"); + const emCall = commitUpdateCalls.find((c) => c.tag === "em"); + expect(spanCall).toBeDefined(); + expect(emCall).toBeDefined(); + expect(spanCall!.nextProps.color).toBe("blue"); + expect(emCall!.nextProps.size).toBe("small"); + }); + + it("excess old children remain when new children are fewer", () => { + const { host, instances } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", null, h("span", null), h("em", null))); + const countAfterFirst = instances.length; + + root.render(h("div", null, h("span", null))); + expect(instances.length).toBe(countAfterFirst); + expect(root.rootFiber!.children[0]!.children.length).toBe(2); + }); + + it("text node props are reconciled on re-render", () => { + const { host, prepareUpdateCalls } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", null, "hello")); + prepareUpdateCalls.length = 0; + + root.render(h("div", null, "world")); + const textCall = prepareUpdateCalls.find((c) => c.tag === "#text"); + expect(textCall).toBeDefined(); + expect(textCall!.prevProps.text).toBe("hello"); + expect(textCall!.nextProps.text).toBe("world"); + }); + + it("function components are transparent during reconciliation", () => { + const { host, commitUpdateCalls } = makeHost(); + const MyComp = createComponent("MyComp", (props) => h("section", { id: props.id as string })); + const root = createHostRoot(host, {}); + root.render(h(MyComp, { id: "1" })); + commitUpdateCalls.length = 0; + + root.render(h(MyComp, { id: "2" })); + const sectionCall = commitUpdateCalls.find((c) => c.tag === "section"); + expect(sectionCall).toBeDefined(); + expect(sectionCall!.nextProps.id).toBe("2"); + }); + + it("existing tests still pass — rootFiber null before render", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + expect(root.rootFiber).toBeNull(); + }); + + it("existing tests still pass — rootFiber set after render", () => { + const { host } = makeHost(); + const root = createHostRoot(host, {}); + root.render(h("div", null, "hello")); + expect(root.rootFiber).not.toBeNull(); + }); +}); \ No newline at end of file