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:
244
test/value-diff-payloads.test.ts
Normal file
244
test/value-diff-payloads.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user