import { describe, it, expect, beforeEach } from "vitest"; import { Value } from "@alkdev/typebox/value"; 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, commitEffects, commitHashes, resetUpdateQueue, wireSignalToFiber } from "../src/host/reconcile.js"; import { signal } from "../src/core/reactive.js"; function makeTrackingHost() { const prepareUpdateCalls: { instance: string; tag: string; prevProps: Record; nextProps: Record }[] = []; const commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record; nextProps: Record }[] = []; const host: HostConfig> = { name: "tracking", createRootContext: () => ({}), createInstance: (tag, props) => `${tag}_${tag}_inst`, createTextInstance: (text) => `text:${text}`, appendChild: () => {}, prepareUpdate: (_instance, _tag, prevProps, nextProps) => { prepareUpdateCalls.push({ instance: _instance as string, tag: _tag as string, 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: { ...prevProps }, nextProps: { ...nextProps } }); }, }; return { host, prepareUpdateCalls, commitUpdateCalls }; } describe("Value.Hash O(1) change detection", () => { beforeEach(() => { resetUpdateQueue(); }); describe("fiber has hash field", () => { it("fibers created during mount have hash field initialized to null", () => { const { host } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); const divFiber = root.rootFiber!.children[0]!; expect(divFiber.hash).toBeNull(); }); it("commitHashes caches hash on each fiber", () => { const { host } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" }, h("span", { label: "a" }))); commitHashes(root.rootFiber!); const divFiber = root.rootFiber!.children[0]!; expect(divFiber.hash).not.toBeNull(); expect(typeof divFiber.hash).toBe("bigint"); const spanFiber = divFiber.children[0]!; expect(spanFiber.hash).not.toBeNull(); expect(typeof spanFiber.hash).toBe("bigint"); }); }); describe("hash match skips subtree (fast path)", () => { it("identical re-render with cached hash skips prepareUpdate", () => { const { host, prepareUpdateCalls, commitUpdateCalls } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); commitHashes(root.rootFiber!); const divFiber = root.rootFiber!.children[0]!; expect(divFiber.hash).not.toBeNull(); 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 and cached hash skips all prepareUpdate", () => { const { host, prepareUpdateCalls } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" }, h("span", { label: "a" }))); commitHashes(root.rootFiber!); prepareUpdateCalls.length = 0; root.render(h("div", { color: "red" }, h("span", { label: "a" }))); expect(prepareUpdateCalls.length).toBe(0); }); it("deeply nested unchanged subtree with hashes skips all prepareUpdate", () => { const { host, prepareUpdateCalls } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render( h("div", { id: "1" }, h("section", { class: "outer" }, h("p", { align: "center" }, "text"), ), ), ); commitHashes(root.rootFiber!); prepareUpdateCalls.length = 0; root.render( h("div", { id: "1" }, h("section", { class: "outer" }, h("p", { align: "center" }, "text"), ), ), ); expect(prepareUpdateCalls.length).toBe(0); }); }); describe("hash mismatch falls through to Value.Equal / reconciliation", () => { it("hash mismatch with changed prop triggers commitUpdate", () => { const { host, commitUpdateCalls } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); commitHashes(root.rootFiber!); commitUpdateCalls.length = 0; root.render(h("div", { color: "blue" })); expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1); }); it("hash mismatch for child triggers commitUpdate for child only", () => { const { host, commitUpdateCalls } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" }, h("span", { label: "a" }))); commitHashes(root.rootFiber!); 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("hash is computed outside reactive computations", () => { it("Value.Hash is never called from within a computed or effect callback", async () => { const { host, commitUpdateCalls } = makeTrackingHost(); const s = signal("red"); const root = createHostRoot(host, {}); root.render(h("div", { color: s.value })); const divFiber = root.rootFiber!.children[0]!; commitHashes(root.rootFiber!); wireSignalToFiber( divFiber, () => h("div", { color: s.value }), host as HostConfig, {}, ); commitUpdateCalls.length = 0; s.value = "blue"; await new Promise((resolve) => { queueMicrotask(() => resolve()); }); expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1); expect(divFiber.props.color).toBe("blue"); const newHash = divFiber.hash; expect(newHash).not.toBeNull(); commitUpdateCalls.length = 0; s.value = "blue"; await new Promise((resolve) => { queueMicrotask(() => resolve()); }); expect(commitUpdateCalls.length).toBe(0); }); it("commitHashes caches hash after commitEffects (commit phase)", () => { const { host } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); const divFiber = root.rootFiber!.children[0]!; expect(divFiber.hash).toBeNull(); commitEffects(root.rootFiber!, host as HostConfig, {}); commitHashes(root.rootFiber!); expect(divFiber.hash).not.toBeNull(); }); }); describe("reconcileProps direct call with hash comparison", () => { it("reconcileProps with hash match skips when fiber has cached hash", () => { const { host, prepareUpdateCalls } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); const divFiber = root.rootFiber!.children[0]!; commitHashes(root.rootFiber!); expect(divFiber.hash).not.toBeNull(); prepareUpdateCalls.length = 0; reconcileProps(divFiber, h("div", { color: "red" }), host as HostConfig, {}); expect(prepareUpdateCalls.length).toBe(0); expect(divFiber.effect).toBeNull(); }); it("reconcileProps with hash mismatch and Value.Equal match still bails out", () => { const { host, prepareUpdateCalls } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); const divFiber = root.rootFiber!.children[0]!; commitHashes(root.rootFiber!); divFiber.hash = BigInt(-1); prepareUpdateCalls.length = 0; reconcileProps(divFiber, h("div", { color: "red" }), host as HostConfig, {}); expect(prepareUpdateCalls.length).toBe(0); }); }); describe("hash updated after commit", () => { it("hash is updated after re-render with new props", () => { const { host } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); commitHashes(root.rootFiber!); const divFiber = root.rootFiber!.children[0]!; const originalHash = divFiber.hash; expect(originalHash).not.toBeNull(); root.render(h("div", { color: "blue" })); const newHash = Value.Hash(h("div", { color: "blue" })); expect(divFiber.hash).toBe(newHash); }); }); });