Implement commitMutations for insert/move/remove effects in tree order
Adds commitMutations function to reconcile.ts that processes fiber effects in correct order: removes (reverse), inserts+moves (left-to-right with insertBefore), updates (top-down). Integrates key-based reconciliation pipeline into render() via reconcileNode, resolveUNode, and createFiberForInsert.
This commit is contained in:
@@ -2,7 +2,8 @@ import type { UNode, UElement, URoot, ComponentFn, UComponent } from "../core/sc
|
|||||||
import { isURoot, isUPrimitive } from "../core/schema.js";
|
import { isURoot, isUPrimitive } from "../core/schema.js";
|
||||||
import { Context } from "../core/context.js";
|
import { Context } from "../core/context.js";
|
||||||
import type { Fiber } from "./fiber.js";
|
import type { Fiber } from "./fiber.js";
|
||||||
import { reconcileProps, commitEffects } from "./reconcile.js";
|
import { reconcileProps, reconcileChildren, commitMutations } from "./reconcile.js";
|
||||||
|
import type { CommitContext } from "./reconcile.js";
|
||||||
|
|
||||||
export interface HostConfig<TTag extends string, Instance, RootCtx> {
|
export interface HostConfig<TTag extends string, Instance, RootCtx> {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -116,6 +117,130 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
|||||||
return fiber;
|
return fiber;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFiberForInsert(node: UNode, parentFiber: Fiber<Instance>): Fiber<Instance> {
|
||||||
|
if (isUPrimitive(node)) {
|
||||||
|
const text = node === null ? "" : String(node);
|
||||||
|
const t = host.createTextInstance(text, ctx, parentFiber.instance);
|
||||||
|
host.emit?.("instance.create", `text_${Date.now()}`, { kind: "text", value: text });
|
||||||
|
return {
|
||||||
|
instance: t,
|
||||||
|
tag: "#text",
|
||||||
|
props: { text },
|
||||||
|
key: undefined,
|
||||||
|
children: [],
|
||||||
|
parent: parentFiber,
|
||||||
|
effect: null,
|
||||||
|
signalDisposers: [],
|
||||||
|
prevProps: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = node as UElement;
|
||||||
|
|
||||||
|
if (typeof el.type === "function") {
|
||||||
|
const component = el.type as ComponentFn;
|
||||||
|
const out = component({ ...el.props, children: el.children });
|
||||||
|
host.emit?.("component.invoke", `comp_${Date.now()}`, { type: (component as unknown as UComponent).displayName ?? "anonymous" });
|
||||||
|
return createFiberForInsert(out, parentFiber);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tag = el.type as TTag;
|
||||||
|
const inst = host.createInstance(tag, el.props as Record<string, unknown>, ctx, parentFiber.instance);
|
||||||
|
host.emit?.("instance.create", `${tag}_${Date.now()}`, { kind: "element", tag, props: el.props });
|
||||||
|
|
||||||
|
const fiber: Fiber<Instance> = {
|
||||||
|
instance: inst,
|
||||||
|
tag,
|
||||||
|
props: el.props as Record<string, unknown>,
|
||||||
|
key: el.key,
|
||||||
|
children: [],
|
||||||
|
parent: parentFiber,
|
||||||
|
effect: null,
|
||||||
|
signalDisposers: [],
|
||||||
|
prevProps: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const child of el.children) {
|
||||||
|
const childFiber = createFiberForInsert(child, fiber);
|
||||||
|
if (childFiber) {
|
||||||
|
host.appendChild(inst, childFiber.instance, ctx);
|
||||||
|
fiber.children.push(childFiber);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fiber;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUNode(node: UNode): UNode[] {
|
||||||
|
if (node == null || node === false) return [];
|
||||||
|
if (isUPrimitive(node)) return [node];
|
||||||
|
if (isURoot(node)) {
|
||||||
|
const result: UNode[] = [];
|
||||||
|
for (const child of (node as URoot).children) {
|
||||||
|
result.push(...resolveUNode(child));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const el = node as UElement;
|
||||||
|
if (typeof el.type === "function") {
|
||||||
|
const component = el.type as ComponentFn;
|
||||||
|
const out = component({ ...el.props, children: el.children });
|
||||||
|
return resolveUNode(out);
|
||||||
|
}
|
||||||
|
return [node];
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcileNode(fiber: Fiber<Instance>, nextNode: UNode): void {
|
||||||
|
if (isUPrimitive(nextNode)) {
|
||||||
|
if (fiber.tag === "#text") {
|
||||||
|
reconcileProps(fiber, nextNode, host as HostConfig<string, Instance, unknown>, ctx as unknown);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isURoot(nextNode)) {
|
||||||
|
const rootChildren = (nextNode as URoot).children;
|
||||||
|
const classification = reconcileChildren(
|
||||||
|
fiber.children,
|
||||||
|
rootChildren,
|
||||||
|
);
|
||||||
|
for (const m of classification.matched) {
|
||||||
|
reconcileProps(m.oldFiber, m.newChild, host as HostConfig<string, Instance, unknown>, ctx as unknown);
|
||||||
|
}
|
||||||
|
const commitCtx: CommitContext<Instance> = {
|
||||||
|
host: host as HostConfig<string, Instance, unknown>,
|
||||||
|
ctx: ctx as unknown,
|
||||||
|
createFiber: (node, parent) => createFiberForInsert(node, parent),
|
||||||
|
};
|
||||||
|
commitMutations(fiber, classification, commitCtx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const el = nextNode 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;
|
||||||
|
|
||||||
|
reconcileProps(fiber, el, host as HostConfig<string, Instance, unknown>, ctx as unknown);
|
||||||
|
|
||||||
|
const classification = reconcileChildren(fiber.children, el.children);
|
||||||
|
for (const m of classification.matched) {
|
||||||
|
reconcileProps(m.oldFiber, m.newChild, host as HostConfig<string, Instance, unknown>, ctx as unknown);
|
||||||
|
}
|
||||||
|
const commitCtx: CommitContext<Instance> = {
|
||||||
|
host: host as HostConfig<string, Instance, unknown>,
|
||||||
|
ctx: ctx as unknown,
|
||||||
|
createFiber: (node, parent) => createFiberForInsert(node, parent),
|
||||||
|
};
|
||||||
|
commitMutations(fiber, classification, commitCtx);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
host,
|
host,
|
||||||
ctx,
|
ctx,
|
||||||
@@ -125,13 +250,23 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
|||||||
render(node: UNode) {
|
render(node: UNode) {
|
||||||
if (this.rootFiber) {
|
if (this.rootFiber) {
|
||||||
const payloadChildren = isURoot(node) ? (node as URoot).children : [node];
|
const payloadChildren = isURoot(node) ? (node as URoot).children : [node];
|
||||||
for (let i = 0; i < payloadChildren.length; i++) {
|
const resolvedChildren: UNode[] = [];
|
||||||
const childFiber = this.rootFiber.children[i];
|
for (const child of payloadChildren) {
|
||||||
if (childFiber) {
|
resolvedChildren.push(...resolveUNode(child));
|
||||||
reconcileProps(childFiber, payloadChildren[i]!, host as HostConfig<string, Instance, unknown>, ctx as unknown);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
commitEffects(this.rootFiber, host as HostConfig<string, Instance, unknown>, ctx as unknown);
|
const classification = reconcileChildren(
|
||||||
|
this.rootFiber.children,
|
||||||
|
resolvedChildren,
|
||||||
|
);
|
||||||
|
for (const m of classification.matched) {
|
||||||
|
reconcileNode(m.oldFiber, m.newChild);
|
||||||
|
}
|
||||||
|
const commitCtx: CommitContext<Instance> = {
|
||||||
|
host: host as HostConfig<string, Instance, unknown>,
|
||||||
|
ctx: ctx as unknown,
|
||||||
|
createFiber: (node, parent) => createFiberForInsert(node, parent),
|
||||||
|
};
|
||||||
|
commitMutations(this.rootFiber, classification, commitCtx);
|
||||||
host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length });
|
host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ import type { HostConfig } from "./config.js";
|
|||||||
import type { UNode, UElement } from "../core/schema.js";
|
import type { UNode, UElement } from "../core/schema.js";
|
||||||
import { isUPrimitive, isURoot, isUElement } from "../core/schema.js";
|
import { isUPrimitive, isURoot, isUElement } from "../core/schema.js";
|
||||||
|
|
||||||
|
export interface CommitContext<I> {
|
||||||
|
host: HostConfig<string, I, unknown>;
|
||||||
|
ctx: unknown;
|
||||||
|
createFiber: (node: UNode, parentFiber: Fiber<I>) => Fiber<I>;
|
||||||
|
}
|
||||||
|
|
||||||
interface PendingUpdate {
|
interface PendingUpdate {
|
||||||
fiber: Fiber<unknown>;
|
fiber: Fiber<unknown>;
|
||||||
nextNode: UNode;
|
nextNode: UNode;
|
||||||
@@ -320,4 +326,125 @@ export function reconcileChildren<I>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
return { matched, added, removed, moves };
|
return { matched, added, removed, moves };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function commitMutations<I>(
|
||||||
|
parentFiber: Fiber<I>,
|
||||||
|
classification: ChildClassification<I>,
|
||||||
|
commitCtx: CommitContext<I>,
|
||||||
|
): void {
|
||||||
|
const { host, ctx, createFiber } = commitCtx;
|
||||||
|
const parentInst = parentFiber.instance;
|
||||||
|
|
||||||
|
const movedSet = new Set<Fiber<I>>(classification.moves.values());
|
||||||
|
|
||||||
|
// Map matched index (position in classification.matched[]) for quick lookup
|
||||||
|
const matchedArrayIndex = new Map<MatchedChild<I>, number>();
|
||||||
|
for (let i = 0; i < classification.matched.length; i++) {
|
||||||
|
matchedArrayIndex.set(classification.matched[i]!, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build maps keyed by new-children position
|
||||||
|
const matchedByNewIndex = new Map<number, MatchedChild<I>>();
|
||||||
|
for (const m of classification.matched) {
|
||||||
|
matchedByNewIndex.set(m.index, m);
|
||||||
|
}
|
||||||
|
const addedByIndex = new Map<number, UElement>();
|
||||||
|
for (const { newChild, index } of classification.added) {
|
||||||
|
addedByIndex.set(index, newChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all positions in new-children order
|
||||||
|
const allIndices = new Set<number>();
|
||||||
|
for (const m of classification.matched) allIndices.add(m.index);
|
||||||
|
for (const a of classification.added) allIndices.add(a.index);
|
||||||
|
const sortedIndices = [...allIndices].sort((a, b) => a - b);
|
||||||
|
|
||||||
|
// Phase 1: Removes — reverse order (children before parents, bottom-up)
|
||||||
|
for (let i = classification.removed.length - 1; i >= 0; i--) {
|
||||||
|
const fiber = classification.removed[i]!;
|
||||||
|
host.removeChild?.(parentInst as never, fiber.instance as never, ctx as never);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build new children array and determine placement actions
|
||||||
|
const newChildren: Fiber<I>[] = [];
|
||||||
|
type Placement = { fiber: Fiber<I>; beforeFiber: Fiber<I> | null; kind: "insert" | "move" };
|
||||||
|
const placements: Placement[] = [];
|
||||||
|
|
||||||
|
// Create fibers for added children first (so they're available for before-lookup)
|
||||||
|
const addedFibers = new Map<number, Fiber<I>>();
|
||||||
|
for (const { index } of classification.added) {
|
||||||
|
const newChild = addedByIndex.get(index)!;
|
||||||
|
const fiber = createFiber(newChild, parentFiber);
|
||||||
|
addedFibers.set(index, fiber);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the final children array
|
||||||
|
for (const idx of sortedIndices) {
|
||||||
|
const addedChild = addedByIndex.get(idx);
|
||||||
|
if (addedChild !== undefined) {
|
||||||
|
const fiber = addedFibers.get(idx)!;
|
||||||
|
newChildren.push(fiber);
|
||||||
|
// Find before: next fiber in newChildren that is "staying" (not moved, not just inserted)
|
||||||
|
const before = findNextStayingFiber(sortedIndices, matchedByNewIndex, addedByIndex, movedSet, idx);
|
||||||
|
placements.push({ fiber, beforeFiber: before, kind: "insert" });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = matchedByNewIndex.get(idx);
|
||||||
|
if (match !== undefined) {
|
||||||
|
const fiber = match.oldFiber;
|
||||||
|
newChildren.push(fiber);
|
||||||
|
const mIdx = matchedArrayIndex.get(match)!;
|
||||||
|
if (classification.moves.has(mIdx)) {
|
||||||
|
const before = findNextStayingFiber(sortedIndices, matchedByNewIndex, addedByIndex, movedSet, idx);
|
||||||
|
placements.push({ fiber, beforeFiber: before, kind: "move" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 2: Inserts + Moves — left-to-right
|
||||||
|
for (const { fiber, beforeFiber } of placements) {
|
||||||
|
if (host.insertBefore && beforeFiber) {
|
||||||
|
host.insertBefore(
|
||||||
|
parentInst as never,
|
||||||
|
fiber.instance as never,
|
||||||
|
beforeFiber.instance as never,
|
||||||
|
ctx as never,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
host.appendChild(parentInst as never, fiber.instance as never, ctx as never);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update fiber tree
|
||||||
|
parentFiber.children = newChildren;
|
||||||
|
for (const fiber of classification.removed) {
|
||||||
|
fiber.parent = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: Updates — top-down (parent before child) via commitEffects
|
||||||
|
commitEffects(parentFiber, host as HostConfig<string, I, unknown>, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findNextStayingFiber<I>(
|
||||||
|
sortedIndices: number[],
|
||||||
|
matchedByNewIndex: Map<number, MatchedChild<I>>,
|
||||||
|
addedByIndex: Map<number, UElement>,
|
||||||
|
movedSet: Set<Fiber<I>>,
|
||||||
|
currentIdx: number,
|
||||||
|
): Fiber<I> | null {
|
||||||
|
const posInSorted = sortedIndices.indexOf(currentIdx);
|
||||||
|
for (let p = posInSorted + 1; p < sortedIndices.length; p++) {
|
||||||
|
const nextIdx = sortedIndices[p]!;
|
||||||
|
const isAdded = addedByIndex.has(nextIdx);
|
||||||
|
const match = matchedByNewIndex.get(nextIdx);
|
||||||
|
if (!isAdded && match !== undefined) {
|
||||||
|
const isMoved = movedSet.has(match.oldFiber);
|
||||||
|
if (!isMoved) {
|
||||||
|
return match.oldFiber;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
@@ -21,8 +21,8 @@ export type { HostConfig, Root } from "./host/config.js";
|
|||||||
|
|
||||||
export type { Fiber, Effect } from "./host/fiber.js";
|
export type { Fiber, Effect } from "./host/fiber.js";
|
||||||
|
|
||||||
export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, wireSignalToFiber, resetUpdateQueue, reconcileChildren, longestIncreasingSubsequence } from "./host/reconcile.js";
|
export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, commitMutations, wireSignalToFiber, resetUpdateQueue, reconcileChildren, longestIncreasingSubsequence } from "./host/reconcile.js";
|
||||||
export type { MatchedChild, ChildClassification } from "./host/reconcile.js";
|
export type { MatchedChild, ChildClassification, CommitContext } from "./host/reconcile.js";
|
||||||
|
|
||||||
export { TransformRegistry, childCtx, matchesSchema, ctx as transformCtx } from "./transform/registry.js";
|
export { TransformRegistry, childCtx, matchesSchema, ctx as transformCtx } from "./transform/registry.js";
|
||||||
export type { TransformContext, TransformFn, TransformRule } from "./transform/registry.js";
|
export type { TransformContext, TransformFn, TransformRule } from "./transform/registry.js";
|
||||||
382
test/commit-mutations.test.ts
Normal file
382
test/commit-mutations.test.ts
Normal file
@@ -0,0 +1,382 @@
|
|||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import { h } from "../src/core/h.js";
|
||||||
|
import { createRoot as createHostRoot } from "../src/host/config.js";
|
||||||
|
import type { HostConfig } from "../src/host/config.js";
|
||||||
|
import { commitMutations, reconcileChildren } from "../src/host/reconcile.js";
|
||||||
|
import type { CommitContext, ChildClassification } from "../src/host/reconcile.js";
|
||||||
|
import type { Fiber } from "../src/host/fiber.js";
|
||||||
|
|
||||||
|
function makeHost(): {
|
||||||
|
host: HostConfig<string, string, Record<string, unknown>>;
|
||||||
|
operations: string[];
|
||||||
|
instances: { tag: string; props: Record<string, unknown> }[];
|
||||||
|
} {
|
||||||
|
const operations: string[] = [];
|
||||||
|
const instances: { tag: string; props: 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) => {
|
||||||
|
return `text:${text}`;
|
||||||
|
},
|
||||||
|
appendChild: (parent, child) => {
|
||||||
|
operations.push(`appendChild(${parent}, ${child})`);
|
||||||
|
},
|
||||||
|
insertBefore: (parent, child, before) => {
|
||||||
|
operations.push(`insertBefore(${parent}, ${child}, ${before})`);
|
||||||
|
},
|
||||||
|
removeChild: (parent, child) => {
|
||||||
|
operations.push(`removeChild(${parent}, ${child})`);
|
||||||
|
},
|
||||||
|
prepareUpdate: (_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) => {
|
||||||
|
operations.push(`commitUpdate(${instance})`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { host, operations, instances };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("commitMutations", () => {
|
||||||
|
it("adding a child calls host.appendChild", () => {
|
||||||
|
const { host, operations } = makeHost();
|
||||||
|
const root = createHostRoot(host, {});
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A")));
|
||||||
|
|
||||||
|
operations.length = 0;
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
||||||
|
|
||||||
|
expect(operations.some(op => op.includes("appendChild") || op.includes("insertBefore"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removing a child calls host.removeChild", () => {
|
||||||
|
const { host, operations } = makeHost();
|
||||||
|
const root = createHostRoot(host, {});
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
||||||
|
|
||||||
|
operations.length = 0;
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A")));
|
||||||
|
|
||||||
|
expect(operations.some(op => op.includes("removeChild"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reordering children calls host.insertBefore for moved children", () => {
|
||||||
|
const { host, operations } = makeHost();
|
||||||
|
const root = createHostRoot(host, {});
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"), h("li", { key: "c" }, "C")));
|
||||||
|
|
||||||
|
operations.length = 0;
|
||||||
|
root.render(h("ul", null, h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
||||||
|
|
||||||
|
expect(operations.some(op => op.includes("insertBefore"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mixed add+remove+update operations apply in correct order", () => {
|
||||||
|
const { host, operations } = makeHost();
|
||||||
|
const root = createHostRoot(host, {});
|
||||||
|
root.render(h("ul", null, h("li", { key: "a", color: "red" }, "A"), h("li", { key: "b" }, "B")));
|
||||||
|
|
||||||
|
operations.length = 0;
|
||||||
|
root.render(h("ul", null, h("li", { key: "a", color: "blue" }, "A"), h("li", { key: "c" }, "C")));
|
||||||
|
|
||||||
|
const removeIdx = operations.findIndex(op => op.includes("removeChild"));
|
||||||
|
const insertIdx = operations.findIndex(op => op.includes("appendChild") || op.includes("insertBefore"));
|
||||||
|
const updateIdx = operations.findIndex(op => op.includes("commitUpdate"));
|
||||||
|
|
||||||
|
if (removeIdx !== -1 && insertIdx !== -1) {
|
||||||
|
expect(removeIdx).toBeLessThan(insertIdx);
|
||||||
|
}
|
||||||
|
if (insertIdx !== -1 && updateIdx !== -1) {
|
||||||
|
expect(insertIdx).toBeLessThan(updateIdx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insertBefore falls back to appendChild if before is null", () => {
|
||||||
|
const hostWithoutInsertBefore: HostConfig<string, string, Record<string, unknown>> = {
|
||||||
|
name: "test",
|
||||||
|
createRootContext: () => ({}),
|
||||||
|
createInstance: (tag) => `${tag}_0`,
|
||||||
|
createTextInstance: (text) => `text:${text}`,
|
||||||
|
appendChild: (_parent, _child) => {},
|
||||||
|
removeChild: (_parent, _child) => {},
|
||||||
|
prepareUpdate: () => null,
|
||||||
|
};
|
||||||
|
const root = createHostRoot(hostWithoutInsertBefore, {});
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A")));
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fiber tree updated: new children added, removed children pruned", () => {
|
||||||
|
const { host } = makeHost();
|
||||||
|
const root = createHostRoot(host, {});
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
||||||
|
|
||||||
|
const ulFiber = root.rootFiber!.children[0]!;
|
||||||
|
expect(ulFiber.children.length).toBe(2);
|
||||||
|
expect(ulFiber.children[0]!.key).toBe("a");
|
||||||
|
expect(ulFiber.children[1]!.key).toBe("b");
|
||||||
|
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "c" }, "C")));
|
||||||
|
|
||||||
|
expect(ulFiber.children.length).toBe(2);
|
||||||
|
const keys = ulFiber.children.map(c => c.key);
|
||||||
|
expect(keys).toContain("a");
|
||||||
|
expect(keys).toContain("c");
|
||||||
|
expect(keys).not.toContain("b");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fiber tree updated: moved children reordered", () => {
|
||||||
|
const { host } = makeHost();
|
||||||
|
const root = createHostRoot(host, {});
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B"), h("li", { key: "c" }, "C")));
|
||||||
|
|
||||||
|
root.render(h("ul", null, h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
||||||
|
|
||||||
|
const ulFiber = root.rootFiber!.children[0]!;
|
||||||
|
const keys = ulFiber.children.map(c => c.key);
|
||||||
|
expect(keys).toEqual(["c", "a", "b"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes are committed before inserts and moves", () => {
|
||||||
|
const { host, operations } = makeHost();
|
||||||
|
const root = createHostRoot(host, {});
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
||||||
|
|
||||||
|
operations.length = 0;
|
||||||
|
root.render(h("ul", null, h("li", { key: "c" }, "C"), h("li", { key: "a" }, "A")));
|
||||||
|
|
||||||
|
const removeIdx = operations.findIndex(op => op.includes("removeChild"));
|
||||||
|
const insertIdx = operations.findIndex(op => op.includes("appendChild") || op.includes("insertBefore"));
|
||||||
|
|
||||||
|
if (removeIdx !== -1 && insertIdx !== -1) {
|
||||||
|
expect(removeIdx).toBeLessThan(insertIdx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates are committed after inserts and moves", () => {
|
||||||
|
const { host, operations } = makeHost();
|
||||||
|
const root = createHostRoot(host, {});
|
||||||
|
root.render(h("ul", null, h("li", { key: "a", color: "red" }, "A")));
|
||||||
|
|
||||||
|
operations.length = 0;
|
||||||
|
root.render(h("ul", null, h("li", { key: "a", color: "blue" }, "A"), h("li", { key: "b" }, "B")));
|
||||||
|
|
||||||
|
const insertIdx = operations.findIndex(op => op.includes("appendChild") || op.includes("insertBefore"));
|
||||||
|
const updateIdx = operations.findIndex(op => op.includes("commitUpdate"));
|
||||||
|
|
||||||
|
if (insertIdx !== -1 && updateIdx !== -1) {
|
||||||
|
expect(insertIdx).toBeLessThan(updateIdx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removed fibers have parent set to null", () => {
|
||||||
|
const { host } = makeHost();
|
||||||
|
const root = createHostRoot(host, {});
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A"), h("li", { key: "b" }, "B")));
|
||||||
|
|
||||||
|
const ulFiber = root.rootFiber!.children[0]!;
|
||||||
|
const bFiber = ulFiber.children[1]!;
|
||||||
|
|
||||||
|
root.render(h("ul", null, h("li", { key: "a" }, "A")));
|
||||||
|
|
||||||
|
expect(bFiber.parent).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("commitMutations direct", () => {
|
||||||
|
function makeFiber(key: string | undefined, tag: string, instance?: string): Fiber<string> {
|
||||||
|
return {
|
||||||
|
instance: instance ?? `inst-${tag}-${key ?? "nokey"}`,
|
||||||
|
tag,
|
||||||
|
props: {},
|
||||||
|
key,
|
||||||
|
children: [],
|
||||||
|
parent: null,
|
||||||
|
effect: null,
|
||||||
|
signalDisposers: [],
|
||||||
|
prevProps: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeClassification<I>(
|
||||||
|
overrides: Partial<ChildClassification<I>> = {},
|
||||||
|
): ChildClassification<I> {
|
||||||
|
return {
|
||||||
|
matched: [],
|
||||||
|
added: [],
|
||||||
|
removed: [],
|
||||||
|
moves: new Map(),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
it("appendChild for added child at end position", () => {
|
||||||
|
const ops: string[] = [];
|
||||||
|
const parentFiber = makeFiber(undefined, "div", "parent-inst");
|
||||||
|
|
||||||
|
const addedFiber = makeFiber("b", "span", "span-inst");
|
||||||
|
|
||||||
|
const host: HostConfig<string, string, unknown> = {
|
||||||
|
name: "test",
|
||||||
|
createRootContext: () => ({}),
|
||||||
|
createInstance: () => "inst",
|
||||||
|
createTextInstance: () => "text",
|
||||||
|
appendChild: (p, c) => ops.push(`appendChild(${p},${c})`),
|
||||||
|
removeChild: (p, c) => ops.push(`removeChild(${p},${c})`),
|
||||||
|
};
|
||||||
|
|
||||||
|
const existingFiber = makeFiber("a", "div", "a-inst");
|
||||||
|
existingFiber.parent = parentFiber;
|
||||||
|
parentFiber.children = [existingFiber];
|
||||||
|
|
||||||
|
const classification = makeClassification({
|
||||||
|
matched: [{ oldFiber: existingFiber, newChild: { type: "div", props: {}, children: [], key: "a" }, index: 0 }],
|
||||||
|
added: [{ newChild: { type: "span", props: {}, children: [], key: "b" }, index: 1 }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const commitCtx: CommitContext<string> = {
|
||||||
|
host: host as HostConfig<string, string, unknown>,
|
||||||
|
ctx: {},
|
||||||
|
createFiber: () => addedFiber,
|
||||||
|
};
|
||||||
|
|
||||||
|
commitMutations(parentFiber, classification, commitCtx);
|
||||||
|
|
||||||
|
expect(ops).toContain("appendChild(parent-inst,span-inst)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removeChild called for removed child", () => {
|
||||||
|
const ops: string[] = [];
|
||||||
|
const parentFiber = makeFiber(undefined, "div", "parent-inst");
|
||||||
|
|
||||||
|
const removedFiber = makeFiber("b", "span", "b-inst");
|
||||||
|
removedFiber.parent = parentFiber;
|
||||||
|
|
||||||
|
const host: HostConfig<string, string, unknown> = {
|
||||||
|
name: "test",
|
||||||
|
createRootContext: () => ({}),
|
||||||
|
createInstance: () => "inst",
|
||||||
|
createTextInstance: () => "text",
|
||||||
|
appendChild: () => {},
|
||||||
|
removeChild: (p, c) => ops.push(`removeChild(${p},${c})`),
|
||||||
|
};
|
||||||
|
|
||||||
|
const classification = makeClassification({
|
||||||
|
removed: [removedFiber],
|
||||||
|
});
|
||||||
|
|
||||||
|
const commitCtx: CommitContext<string> = {
|
||||||
|
host: host as HostConfig<string, string, unknown>,
|
||||||
|
ctx: {},
|
||||||
|
createFiber: () => makeFiber("x", "x", "x"),
|
||||||
|
};
|
||||||
|
|
||||||
|
commitMutations(parentFiber, classification, commitCtx);
|
||||||
|
|
||||||
|
expect(ops).toContain("removeChild(parent-inst,b-inst)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("insertBefore called for moved child with staying sibling after it", () => {
|
||||||
|
const ops: string[] = [];
|
||||||
|
const parentFiber = makeFiber(undefined, "div", "parent-inst");
|
||||||
|
|
||||||
|
const aFiber = makeFiber("a", "div", "a-inst");
|
||||||
|
aFiber.parent = parentFiber;
|
||||||
|
const bFiber = makeFiber("b", "span", "b-inst");
|
||||||
|
bFiber.parent = parentFiber;
|
||||||
|
const cFiber = makeFiber("c", "p", "c-inst");
|
||||||
|
cFiber.parent = parentFiber;
|
||||||
|
parentFiber.children = [aFiber, bFiber, cFiber];
|
||||||
|
|
||||||
|
const host: HostConfig<string, string, unknown> = {
|
||||||
|
name: "test",
|
||||||
|
createRootContext: () => ({}),
|
||||||
|
createInstance: () => "inst",
|
||||||
|
createTextInstance: () => "text",
|
||||||
|
appendChild: (p, c) => ops.push(`appendChild(${p},${c})`),
|
||||||
|
insertBefore: (p, c, b) => ops.push(`insertBefore(${p},${c},${b})`),
|
||||||
|
removeChild: (p, c) => ops.push(`removeChild(${p},${c})`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reorder: [b, a, c] → moves b before a (a is staying)
|
||||||
|
const classification: ChildClassification<string> = {
|
||||||
|
matched: [
|
||||||
|
{ oldFiber: bFiber, newChild: { type: "span", props: {}, children: [], key: "b" }, index: 0 },
|
||||||
|
{ oldFiber: aFiber, newChild: { type: "div", props: {}, children: [], key: "a" }, index: 1 },
|
||||||
|
{ oldFiber: cFiber, newChild: { type: "p", props: {}, children: [], key: "c" }, index: 2 },
|
||||||
|
],
|
||||||
|
added: [],
|
||||||
|
removed: [],
|
||||||
|
moves: new Map([[0, bFiber]]),
|
||||||
|
};
|
||||||
|
|
||||||
|
const commitCtx: CommitContext<string> = {
|
||||||
|
host: host as HostConfig<string, string, unknown>,
|
||||||
|
ctx: {},
|
||||||
|
createFiber: () => makeFiber("x", "x", "x"),
|
||||||
|
};
|
||||||
|
|
||||||
|
commitMutations(parentFiber, classification, commitCtx);
|
||||||
|
|
||||||
|
expect(ops.some(op => op.includes("insertBefore"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates committed top-down (parent before child)", () => {
|
||||||
|
const ops: string[] = [];
|
||||||
|
const parentFiber = makeFiber(undefined, "div", "parent-inst");
|
||||||
|
parentFiber.effect = { type: "update", payload: {} };
|
||||||
|
parentFiber.prevProps = {};
|
||||||
|
parentFiber.props = { color: "blue" };
|
||||||
|
|
||||||
|
const childFiber = makeFiber("a", "span", "child-inst");
|
||||||
|
childFiber.parent = parentFiber;
|
||||||
|
childFiber.effect = { type: "update", payload: {} };
|
||||||
|
childFiber.prevProps = {};
|
||||||
|
childFiber.props = { size: "big" };
|
||||||
|
parentFiber.children = [childFiber];
|
||||||
|
|
||||||
|
const host: HostConfig<string, string, unknown> = {
|
||||||
|
name: "test",
|
||||||
|
createRootContext: () => ({}),
|
||||||
|
createInstance: () => "inst",
|
||||||
|
createTextInstance: () => "text",
|
||||||
|
appendChild: () => {},
|
||||||
|
commitUpdate: (inst) => ops.push(`commitUpdate(${inst})`),
|
||||||
|
};
|
||||||
|
|
||||||
|
const classification = makeClassification<string>({
|
||||||
|
matched: [{
|
||||||
|
oldFiber: childFiber,
|
||||||
|
newChild: { type: "span", props: { size: "big" }, children: [], key: "a" },
|
||||||
|
index: 0,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
const commitCtx: CommitContext<string> = {
|
||||||
|
host: host as HostConfig<string, string, unknown>,
|
||||||
|
ctx: {},
|
||||||
|
createFiber: () => makeFiber("x", "x", "x"),
|
||||||
|
};
|
||||||
|
|
||||||
|
commitMutations(parentFiber, classification, commitCtx);
|
||||||
|
|
||||||
|
const parentIdx = ops.indexOf("commitUpdate(parent-inst)");
|
||||||
|
const childIdx = ops.indexOf("commitUpdate(child-inst)");
|
||||||
|
expect(parentIdx).toBeLessThan(childIdx);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -133,15 +133,14 @@ describe("Root.render() re-renderable", () => {
|
|||||||
expect(emCall!.nextProps.size).toBe("small");
|
expect(emCall!.nextProps.size).toBe("small");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("excess old children remain when new children are fewer", () => {
|
it("excess old children are removed when new children are fewer", () => {
|
||||||
const { host, instances } = makeHost();
|
const { host, instances } = makeHost();
|
||||||
const root = createHostRoot(host, {});
|
const root = createHostRoot(host, {});
|
||||||
root.render(h("div", null, h("span", null), h("em", null)));
|
root.render(h("div", null, h("span", null), h("em", null)));
|
||||||
const countAfterFirst = instances.length;
|
|
||||||
|
|
||||||
root.render(h("div", null, h("span", null)));
|
root.render(h("div", null, h("span", null)));
|
||||||
expect(instances.length).toBe(countAfterFirst);
|
expect(root.rootFiber!.children[0]!.children.length).toBe(1);
|
||||||
expect(root.rootFiber!.children[0]!.children.length).toBe(2);
|
expect(root.rootFiber!.children[0]!.children[0]!.tag).toBe("span");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("text node props are reconciled on re-render", () => {
|
it("text node props are reconciled on re-render", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user