import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { h } from "../src/core/h.js"; import { signal, batch } from "../src/core/reactive.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 { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, wireSignalToFiber, resetUpdateQueue } from "../src/host/reconcile.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 instances: { tag: string; props: Record }[] = []; const texts: string[] = []; const appends: { parent: string; child: string }[] = []; const host: HostConfig> = { 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 = {}; 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("signal-driven-updates", () => { beforeEach(() => { resetUpdateQueue(); }); describe("wireSignalToFiber", () => { it("signal change triggers commitUpdate", async () => { const { host, commitUpdateCalls } = makeTrackingHost(); const color = signal("red"); const root = createHostRoot(host, {}); root.render(h("div", { color: color.value })); const divFiber = root.rootFiber!.children[0]!; expect(divFiber.tag).toBe("div"); wireSignalToFiber( divFiber, () => h("div", { color: color.value }), host as HostConfig, {}, ); commitUpdateCalls.length = 0; color.value = "blue"; await new Promise((resolve) => { queueMicrotask(() => resolve()); }); expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1); const lastCommit = commitUpdateCalls[commitUpdateCalls.length - 1]!; expect(lastCommit.instance).toBe(divFiber.instance); }); it("signal effect disposer stored in fiber.signalDisposers", () => { const { host } = makeTrackingHost(); const color = signal("red"); const root = createHostRoot(host, {}); root.render(h("div", { color: color.value })); const divFiber = root.rootFiber!.children[0]!; const beforeCount = divFiber.signalDisposers.length; wireSignalToFiber( divFiber, () => h("div", { color: color.value }), host as HostConfig, {}, ); expect(divFiber.signalDisposers.length).toBe(beforeCount + 1); expect(typeof divFiber.signalDisposers[divFiber.signalDisposers.length - 1]).toBe("function"); }); it("disposing signal via signalDisposers stops updates", async () => { const { host, commitUpdateCalls } = makeTrackingHost(); const color = signal("red"); const root = createHostRoot(host, {}); root.render(h("div", { color: color.value })); const divFiber = root.rootFiber!.children[0]!; wireSignalToFiber( divFiber, () => h("div", { color: color.value }), host as HostConfig, {}, ); await new Promise((resolve) => { queueMicrotask(() => resolve()); }); resetUpdateQueue(); const disposer = divFiber.signalDisposers.pop()!; disposer(); const callCountBefore = commitUpdateCalls.filter((c) => c.tag === "div").length; color.value = "green"; await new Promise((resolve) => { queueMicrotask(() => resolve()); }); expect(commitUpdateCalls.filter((c) => c.tag === "div").length).toBe(callCountBefore); }); }); describe("batch of signal changes", () => { it("batch of signal changes results in single reconciliation pass", async () => { const { host, commitUpdateCalls } = makeTrackingHost(); const color = signal("red"); const size = signal("small"); const root = createHostRoot(host, {}); root.render(h("div", { color: color.value, size: size.value })); const divFiber = root.rootFiber!.children[0]!; wireSignalToFiber( divFiber, () => h("div", { color: color.value, size: size.value }), host as HostConfig, {}, ); await new Promise((resolve) => { queueMicrotask(() => resolve()); }); resetUpdateQueue(); commitUpdateCalls.length = 0; batch(() => { color.value = "blue"; size.value = "large"; }); await new Promise((resolve) => { queueMicrotask(() => resolve()); }); expect(commitUpdateCalls.filter((c) => c.tag === "div").length).toBe(1); }); }); describe("render() re-renderable", () => { it("second render reconciles props against existing fiber tree", () => { const { host, commitUpdateCalls, instances } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" }, "hello")); expect(instances.length).toBe(1); const instanceCountBefore = instances.length; root.render(h("div", { color: "blue" }, "hello")); expect(instances.length).toBe(instanceCountBefore); const divCommitCalls = commitUpdateCalls.filter((c) => c.tag === "div"); expect(divCommitCalls.length).toBe(1); expect(divCommitCalls[0]!.prevProps.color).toBe("red"); expect(divCommitCalls[0]!.nextProps.color).toBe("blue"); }); it("second render does not create duplicate instances", () => { const { host, instances } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); expect(instances.length).toBe(1); root.render(h("div", { color: "blue" })); expect(instances.length).toBe(1); }); it("commitUpdate is called for changed props on re-render", () => { const { host, commitUpdateCalls } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("span", { label: "a" })); root.render(h("span", { label: "b" })); const spanCommits = commitUpdateCalls.filter((c) => c.tag === "span"); expect(spanCommits.length).toBe(1); expect(spanCommits[0]!.prevProps.label).toBe("a"); expect(spanCommits[0]!.nextProps.label).toBe("b"); }); }); describe("prepareUpdate/commitUpdate optional", () => { it("host without prepareUpdate is a no-op on re-render", () => { const host: HostConfig> = { name: "minimal", createRootContext: () => ({}), createInstance: (tag) => tag, createTextInstance: (text) => text, appendChild: () => {}, }; const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); expect(() => root.render(h("div", { color: "blue" }))).not.toThrow(); }); }); describe("commitEffects top-down order", () => { it("parent commitUpdate fires before child commitUpdate", () => { const { host, commitUpdateCalls } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" }, h("span", { label: "a" }))); root.render(h("div", { color: "blue" }, h("span", { label: "b" }))); const tags = commitUpdateCalls.map((c) => c.tag); const divIdx = tags.indexOf("div"); const spanIdx = tags.indexOf("span"); expect(divIdx).toBeLessThan(spanIdx); }); }); describe("reconcileProps direct call", () => { it("updates fiber props and sets effect when prepareUpdate returns payload", () => { const { host } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); const divFiber = root.rootFiber!.children[0]!; expect(divFiber.props.color).toBe("red"); reconcileProps(divFiber, h("div", { color: "blue" }), host as HostConfig, {}); expect(divFiber.props.color).toBe("blue"); expect(divFiber.effect).not.toBeNull(); expect(divFiber.effect!.type).toBe("update"); expect(divFiber.prevProps!.color).toBe("red"); }); it("no effect when props are unchanged", () => { const { host } = makeTrackingHost(); const root = createHostRoot(host, {}); root.render(h("div", { color: "red" })); const divFiber = root.rootFiber!.children[0]!; reconcileProps(divFiber, h("div", { color: "red" }), host as HostConfig, {}); expect(divFiber.effect).toBeNull(); }); }); });