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; nextProps: Record; }[] = []; const prepareUpdateCalls: { instance: string; tag: string; prevProps: Record; nextProps: Record; }[] = []; const host: HostConfig> = { 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 = {}; 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> = { 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, {}, ); 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"); }); }); });