feat: add Value.Diff granular prop payloads for commitUpdate

Value.Diff produces property-level diff payloads instead of relying
solely on prepareUpdate. Function-valued props are stripped before
diffing; ValueDiffError is caught and falls back to prepareUpdate.
Value.Equal and Value.Clone now safely handle function-valued props
with try-catch fallbacks.
This commit is contained in:
2026-05-18 17:44:27 +00:00
parent 27c1f2671b
commit 32b2e20c54
3 changed files with 315 additions and 36 deletions

View File

@@ -0,0 +1,244 @@
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 { reconcileProps, commitEffects, resetUpdateQueue } from "../src/host/reconcile.js";
import type { Fiber } from "../src/host/fiber.js";
function makeDiffTrackingHost() {
const commitUpdateCalls: {
instance: string;
payload: unknown;
tag: string;
prevProps: Record<string, unknown>;
nextProps: Record<string, unknown>;
}[] = [];
const prepareUpdateCalls: {
instance: string;
tag: string;
prevProps: Record<string, unknown>;
nextProps: Record<string, unknown>;
}[] = [];
const host: HostConfig<string, string, Record<string, unknown>> = {
name: "diff-tracking",
createRootContext: () => ({}),
createInstance: (tag, _props) => `${tag}_inst`,
createTextInstance: (text) => `text:${text}`,
appendChild: () => {},
prepareUpdate: (_instance, tag, prevProps, nextProps) => {
prepareUpdateCalls.push({ instance: _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: instance as string,
payload,
tag: tag as string,
prevProps,
nextProps,
});
},
};
return { host, commitUpdateCalls, prepareUpdateCalls };
}
describe("Value.Diff granular prop payloads", () => {
beforeEach(() => {
resetUpdateQueue();
});
describe("commitUpdate receives Value.Diff payload with only changed keys", () => {
it("diff payload contains only changed props", () => {
const { host, commitUpdateCalls } = makeDiffTrackingHost();
const root = createHostRoot(host, {});
root.render(h("div", { color: "red", size: 10 }));
commitUpdateCalls.length = 0;
root.render(h("div", { color: "blue", size: 10 }));
const divCall = commitUpdateCalls.find((c) => c.tag === "div");
expect(divCall).toBeDefined();
const payload = divCall!.payload as Array<{ type: string; path: string; value: unknown }>;
expect(Array.isArray(payload)).toBe(true);
expect(payload.length).toBe(1);
expect(payload[0]!.type).toBe("update");
expect(payload[0]!.path).toBe("/color");
expect(payload[0]!.value).toBe("blue");
});
it("diff payload with multiple changed props", () => {
const { host, commitUpdateCalls } = makeDiffTrackingHost();
const root = createHostRoot(host, {});
root.render(h("div", { color: "red", size: 10, label: "a" }));
commitUpdateCalls.length = 0;
root.render(h("div", { color: "blue", size: 20, label: "a" }));
const divCall = commitUpdateCalls.find((c) => c.tag === "div");
expect(divCall).toBeDefined();
const payload = divCall!.payload as Array<{ type: string; path: string; value: unknown }>;
expect(Array.isArray(payload)).toBe(true);
const paths = payload.map((d) => d.path).sort();
expect(paths).toEqual(["/color", "/size"]);
});
it("no changes produces no commitUpdate", () => {
const { host, commitUpdateCalls } = makeDiffTrackingHost();
const root = createHostRoot(host, {});
root.render(h("div", { color: "red" }));
commitUpdateCalls.length = 0;
root.render(h("div", { color: "red" }));
expect(commitUpdateCalls.length).toBe(0);
});
it("added prop appears as insert in diff payload", () => {
const { host, commitUpdateCalls } = makeDiffTrackingHost();
const root = createHostRoot(host, {});
root.render(h("div", { color: "red" }));
commitUpdateCalls.length = 0;
root.render(h("div", { color: "red", size: 10 }));
const divCall = commitUpdateCalls.find((c) => c.tag === "div");
expect(divCall).toBeDefined();
const payload = divCall!.payload as Array<{ type: string; path: string; value: unknown }>;
expect(Array.isArray(payload)).toBe(true);
expect(payload.some((d) => d.type === "insert" && d.path === "/size")).toBe(true);
});
it("removed prop appears as delete in diff payload", () => {
const { host, commitUpdateCalls } = makeDiffTrackingHost();
const root = createHostRoot(host, {});
root.render(h("div", { color: "red", size: 10 }));
commitUpdateCalls.length = 0;
root.render(h("div", { color: "red" }));
const divCall = commitUpdateCalls.find((c) => c.tag === "div");
expect(divCall).toBeDefined();
const payload = divCall!.payload as Array<{ type: string; path: string; value: unknown }>;
expect(Array.isArray(payload)).toBe(true);
expect(payload.some((d) => d.type === "delete" && d.path === "/size")).toBe(true);
});
});
describe("function-valued props don't crash Value.Diff (stripped or caught)", () => {
it("element with function props still updates correctly", () => {
const { host, commitUpdateCalls } = makeDiffTrackingHost();
const root = createHostRoot(host, {});
const onClick = () => {};
root.render(h("div", { color: "red", onClick }));
commitUpdateCalls.length = 0;
const onClick2 = () => {};
root.render(h("div", { color: "blue", onClick: onClick2 }));
const divCall = commitUpdateCalls.find((c) => c.tag === "div");
expect(divCall).toBeDefined();
const payload = divCall!.payload as Array<{ type: string; path: string; value: unknown }>;
expect(Array.isArray(payload)).toBe(true);
expect(payload.some((d) => d.path === "/color" && d.value === "blue")).toBe(true);
});
it("only function props changed — no diff entries for functions in Value.Diff payload", () => {
const { host, commitUpdateCalls } = makeDiffTrackingHost();
const root = createHostRoot(host, {});
const onClick1 = () => {};
root.render(h("div", { color: "red", onClick: onClick1 }));
commitUpdateCalls.length = 0;
const onClick2 = () => {};
root.render(h("div", { color: "red", onClick: onClick2 }));
const divCall = commitUpdateCalls.find((c) => c.tag === "div");
if (divCall) {
const payload = divCall.payload;
if (Array.isArray(payload)) {
const functionPaths = (payload as Array<{ type: string; path: string; value: unknown }>).filter((d) => d.path === "/onClick");
expect(functionPaths.length).toBe(0);
}
}
});
});
describe("diff error falls back gracefully to prepareUpdate", () => {
it("ValueDiffError from nested function values falls back to prepareUpdate", () => {
let prepareUpdateCalled = false;
const host: HostConfig<string, string, Record<string, unknown>> = {
name: "fallback",
createRootContext: () => ({}),
createInstance: (tag) => `${tag}_inst`,
createTextInstance: (text) => `text:${text}`,
appendChild: () => {},
prepareUpdate: (_instance, _tag, _prevProps, _nextProps) => {
prepareUpdateCalled = true;
return { fallback: true };
},
commitUpdate: () => {},
};
const root = createHostRoot(host, {});
const deepFn = { handler: () => {} };
root.render(h("div", { data: deepFn, color: "red" }));
const divFiber = root.rootFiber!.children[0]!;
const deepFn2 = { handler: () => {} };
reconcileProps(
divFiber,
h("div", { data: deepFn2, color: "blue" }),
host as HostConfig<string, string, unknown>,
{},
);
expect(prepareUpdateCalled).toBe(true);
expect(divFiber.effect).not.toBeNull();
});
});
describe("hosts can still use prevProps/nextProps if they prefer", () => {
it("commitUpdate still receives prevProps and nextProps alongside diff payload", () => {
const { host, commitUpdateCalls } = makeDiffTrackingHost();
const root = createHostRoot(host, {});
root.render(h("div", { color: "red" }));
commitUpdateCalls.length = 0;
root.render(h("div", { color: "blue" }));
const divCall = commitUpdateCalls.find((c) => c.tag === "div");
expect(divCall).toBeDefined();
expect(divCall!.prevProps.color).toBe("red");
expect(divCall!.nextProps.color).toBe("blue");
});
});
});