244 lines
8.5 KiB
TypeScript
244 lines
8.5 KiB
TypeScript
import { describe, it, expect, beforeEach } 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 type { Fiber } from "../src/host/fiber.js";
|
|
import { reconcileProps, resetUpdateQueue } from "../src/host/reconcile.js";
|
|
|
|
function makeTrackingHost() {
|
|
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 instances: { tag: string; props: Record<string, unknown> }[] = [];
|
|
const texts: string[] = [];
|
|
const appends: { parent: string; child: string }[] = [];
|
|
|
|
const host: HostConfig<string, string, Record<string, unknown>> = {
|
|
name: "tracking",
|
|
createRootContext: () => ({}),
|
|
createInstance: (tag, props) => {
|
|
const id = `${tag}_${instances.length}`;
|
|
instances.push({ tag, props });
|
|
return id;
|
|
},
|
|
createTextInstance: (text) => {
|
|
texts.push(text);
|
|
return `text_${texts.length - 1}`;
|
|
},
|
|
appendChild: (parent, child) => {
|
|
appends.push({ parent, child });
|
|
},
|
|
prepareUpdate: (instance, tag, prevProps, nextProps) => {
|
|
prepareUpdateCalls.push({ instance, tag, prevProps: { ...prevProps }, nextProps: { ...nextProps } });
|
|
const changed: Record<string, unknown> = {};
|
|
let hasChanges = false;
|
|
for (const key of Object.keys(nextProps)) {
|
|
if (prevProps[key] !== nextProps[key]) {
|
|
changed[key] = nextProps[key];
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
for (const key of Object.keys(prevProps)) {
|
|
if (!(key in nextProps)) {
|
|
changed[key] = undefined;
|
|
hasChanges = true;
|
|
}
|
|
}
|
|
return hasChanges ? changed : null;
|
|
},
|
|
commitUpdate: (instance, payload, tag, prevProps, nextProps) => {
|
|
commitUpdateCalls.push({ instance, payload, tag, prevProps: { ...prevProps }, nextProps: { ...nextProps } });
|
|
},
|
|
};
|
|
|
|
return { host, prepareUpdateCalls, commitUpdateCalls, instances, texts, appends };
|
|
}
|
|
|
|
describe("Value.Equal bail-out", () => {
|
|
beforeEach(() => {
|
|
resetUpdateQueue();
|
|
});
|
|
|
|
describe("unchanged subtree skips prepareUpdate/commitUpdate", () => {
|
|
it("identical re-render skips prepareUpdate for unchanged element", () => {
|
|
const { host, prepareUpdateCalls, commitUpdateCalls } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { color: "red" }));
|
|
|
|
prepareUpdateCalls.length = 0;
|
|
commitUpdateCalls.length = 0;
|
|
|
|
root.render(h("div", { color: "red" }));
|
|
|
|
expect(prepareUpdateCalls.length).toBe(0);
|
|
expect(commitUpdateCalls.length).toBe(0);
|
|
});
|
|
|
|
it("identical re-render with children skips prepareUpdate for parent and children", () => {
|
|
const { host, prepareUpdateCalls, commitUpdateCalls } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { color: "red" }, h("span", { label: "a" })));
|
|
|
|
prepareUpdateCalls.length = 0;
|
|
commitUpdateCalls.length = 0;
|
|
|
|
root.render(h("div", { color: "red" }, h("span", { label: "a" })));
|
|
|
|
expect(prepareUpdateCalls.length).toBe(0);
|
|
expect(commitUpdateCalls.length).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("changed prop still triggers update", () => {
|
|
it("changed prop on parent triggers commitUpdate", () => {
|
|
const { host, commitUpdateCalls } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { color: "red" }));
|
|
|
|
commitUpdateCalls.length = 0;
|
|
|
|
root.render(h("div", { color: "blue" }));
|
|
|
|
expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1);
|
|
const divCall = commitUpdateCalls.find((c) => c.tag === "div");
|
|
expect(divCall).toBeDefined();
|
|
expect(divCall!.prevProps.color).toBe("red");
|
|
expect(divCall!.nextProps.color).toBe("blue");
|
|
});
|
|
|
|
it("changed child prop triggers commitUpdate only for child", () => {
|
|
const { host, commitUpdateCalls } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { color: "red" }, h("span", { label: "a" })));
|
|
|
|
commitUpdateCalls.length = 0;
|
|
|
|
root.render(h("div", { color: "red" }, h("span", { label: "b" })));
|
|
|
|
const spanCalls = commitUpdateCalls.filter((c) => c.tag === "span");
|
|
expect(spanCalls.length).toBe(1);
|
|
expect(spanCalls[0]!.prevProps.label).toBe("a");
|
|
expect(spanCalls[0]!.nextProps.label).toBe("b");
|
|
});
|
|
});
|
|
|
|
describe("deeply nested unchanged subtree is fully skipped", () => {
|
|
it("three levels of unchanged elements are all skipped", () => {
|
|
const { host, prepareUpdateCalls } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(
|
|
h("div", { id: "1" },
|
|
h("section", { class: "outer" },
|
|
h("p", { align: "center" }, "text"),
|
|
),
|
|
),
|
|
);
|
|
|
|
prepareUpdateCalls.length = 0;
|
|
|
|
root.render(
|
|
h("div", { id: "1" },
|
|
h("section", { class: "outer" },
|
|
h("p", { align: "center" }, "text"),
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(prepareUpdateCalls.length).toBe(0);
|
|
});
|
|
|
|
it("deep subtree with one change only commitUpdates the changed node", () => {
|
|
const { host, commitUpdateCalls } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(
|
|
h("div", { id: "1" },
|
|
h("section", { class: "outer" },
|
|
h("p", { align: "center" }),
|
|
),
|
|
),
|
|
);
|
|
|
|
commitUpdateCalls.length = 0;
|
|
|
|
root.render(
|
|
h("div", { id: "1" },
|
|
h("section", { class: "outer" },
|
|
h("p", { align: "left" }),
|
|
),
|
|
),
|
|
);
|
|
|
|
const pCommit = commitUpdateCalls.filter((c) => c.tag === "p");
|
|
expect(pCommit.length).toBe(1);
|
|
expect(pCommit[0]!.prevProps.align).toBe("center");
|
|
expect(pCommit[0]!.nextProps.align).toBe("left");
|
|
});
|
|
});
|
|
|
|
describe("cachedNode is set on mount", () => {
|
|
it("fibers created during mount have cachedNode set", () => {
|
|
const { host } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { color: "red" }, h("span", { label: "a" })));
|
|
|
|
const divFiber = root.rootFiber!.children[0]!;
|
|
expect(divFiber.cachedNode).not.toBeNull();
|
|
const spanFiber = divFiber.children[0]!;
|
|
expect(spanFiber.cachedNode).not.toBeNull();
|
|
});
|
|
|
|
it("cachedNode matches the node used to create the fiber", () => {
|
|
const { host } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
const divNode = h("div", { color: "red" });
|
|
root.render(divNode);
|
|
|
|
const divFiber = root.rootFiber!.children[0]!;
|
|
expect(divFiber.cachedNode).not.toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("reconcileProps direct call with Value.Equal bail-out", () => {
|
|
it("reconcileProps with identical node does not call prepareUpdate", () => {
|
|
const { host, prepareUpdateCalls } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { color: "red" }));
|
|
|
|
const divFiber = root.rootFiber!.children[0]!;
|
|
expect(divFiber.cachedNode).not.toBeNull();
|
|
|
|
prepareUpdateCalls.length = 0;
|
|
|
|
reconcileProps(divFiber, h("div", { color: "red" }), host as HostConfig<string, string, unknown>, {});
|
|
|
|
expect(prepareUpdateCalls.length).toBe(0);
|
|
expect(divFiber.effect).toBeNull();
|
|
});
|
|
|
|
it("reconcileProps with changed node produces an update effect", () => {
|
|
const { host } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
root.render(h("div", { color: "red" }));
|
|
|
|
const divFiber = root.rootFiber!.children[0]!;
|
|
|
|
reconcileProps(divFiber, h("div", { color: "blue" }), host as HostConfig<string, string, unknown>, {});
|
|
|
|
expect(divFiber.effect).not.toBeNull();
|
|
expect(divFiber.props.color).toBe("blue");
|
|
});
|
|
});
|
|
|
|
describe("Value.Equal deep equality", () => {
|
|
it("deep-equal props with different object references still bail out", () => {
|
|
const { host, prepareUpdateCalls } = makeTrackingHost();
|
|
const root = createHostRoot(host, {});
|
|
|
|
root.render(h("div", { style: { color: "red", size: 10 } }));
|
|
prepareUpdateCalls.length = 0;
|
|
|
|
root.render(h("div", { style: { color: "red", size: 10 } }));
|
|
|
|
expect(prepareUpdateCalls.length).toBe(0);
|
|
});
|
|
});
|
|
}); |