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.
This commit is contained in:
@@ -40,6 +40,11 @@ export interface Root<TTag extends string, Instance, RootCtx> {
|
||||
unmount(): void;
|
||||
}
|
||||
|
||||
function resolveChildren(node: UNode): UNode[] {
|
||||
if (isURoot(node)) return (node as URoot).children;
|
||||
return [node];
|
||||
}
|
||||
|
||||
export function createRoot<TTag extends string, Instance, RootCtx>(
|
||||
host: HostConfig<TTag, Instance, RootCtx>,
|
||||
container: unknown,
|
||||
@@ -97,7 +102,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
||||
const fiber: Fiber<Instance> = {
|
||||
instance: inst,
|
||||
tag,
|
||||
props: el.props as Record<string, unknown>,
|
||||
props: { ...el.props } as Record<string, unknown>,
|
||||
key: el.key,
|
||||
children: [],
|
||||
parent: parentFiber,
|
||||
@@ -115,6 +120,95 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
||||
return fiber;
|
||||
}
|
||||
|
||||
function reconcileNode(fiber: Fiber<Instance>, 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<string, unknown>;
|
||||
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<Instance>): 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,6 +216,9 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
||||
context: rootContext,
|
||||
rootFiber: null,
|
||||
render(node: UNode) {
|
||||
const payloadChildren = resolveChildren(node);
|
||||
|
||||
if (this.rootFiber === null) {
|
||||
const root: Fiber<Instance> = {
|
||||
instance: undefined as unknown as Instance,
|
||||
tag: "#root",
|
||||
@@ -133,11 +230,19 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
||||
signalDisposers: [],
|
||||
prevProps: null,
|
||||
};
|
||||
const payloadChildren = isURoot(node) ? (node as URoot).children : [node];
|
||||
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);
|
||||
}
|
||||
|
||||
host.finalizeRoot?.(ctx);
|
||||
host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length });
|
||||
},
|
||||
|
||||
185
test/render-rerenderable.test.ts
Normal file
185
test/render-rerenderable.test.ts
Normal file
@@ -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<string, string, Record<string, unknown>>;
|
||||
instances: { tag: string; props: Record<string, unknown> }[];
|
||||
texts: string[];
|
||||
appends: { parent: string; child: string }[];
|
||||
prepareUpdateCalls: { instance: string; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[];
|
||||
commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[];
|
||||
} {
|
||||
const instances: { tag: string; props: Record<string, unknown> }[] = [];
|
||||
const texts: string[] = [];
|
||||
const appends: { parent: string; child: string }[] = [];
|
||||
const prepareUpdateCalls: { instance: string; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[] = [];
|
||||
const commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[] = [];
|
||||
|
||||
const host: HostConfig<string, string, Record<string, unknown>> = {
|
||||
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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user