Merge feat/signal-driven-updates with conflict resolution (reconcile.ts extracted, render re-renderable preserved)
This commit is contained in:
299
test/signal-driven-updates.test.ts
Normal file
299
test/signal-driven-updates.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
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<string, unknown>; nextProps: Record<string, unknown> }[] = [];
|
||||
const commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[] = [];
|
||||
const instances: { tag: string; props: Record<string, unknown> }[] = [];
|
||||
const texts: string[] = [];
|
||||
const appends: { parent: string; child: string }[] = [];
|
||||
|
||||
const host: HostConfig<string, string, Record<string, unknown>> = {
|
||||
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<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, 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 prepareUpdate + commitUpdate", async () => {
|
||||
const { host, prepareUpdateCalls, 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<string, string, unknown>,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(prepareUpdateCalls.length).toBe(0);
|
||||
expect(commitUpdateCalls.length).toBe(0);
|
||||
|
||||
color.value = "blue";
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
queueMicrotask(() => resolve());
|
||||
});
|
||||
|
||||
expect(prepareUpdateCalls.length).toBeGreaterThanOrEqual(1);
|
||||
const lastPrepare = prepareUpdateCalls[prepareUpdateCalls.length - 1]!;
|
||||
expect(lastPrepare.tag).toBe("div");
|
||||
expect(lastPrepare.prevProps.color).toBe("red");
|
||||
expect(lastPrepare.nextProps.color).toBe("blue");
|
||||
|
||||
expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1);
|
||||
const lastCommit = commitUpdateCalls[commitUpdateCalls.length - 1]!;
|
||||
expect(lastCommit.instance).toBe(divFiber.instance);
|
||||
expect(lastCommit.payload).toEqual({ color: "blue" });
|
||||
});
|
||||
|
||||
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<string, string, unknown>,
|
||||
{},
|
||||
);
|
||||
|
||||
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, prepareUpdateCalls } = 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<string, string, unknown>,
|
||||
{},
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
queueMicrotask(() => resolve());
|
||||
});
|
||||
resetUpdateQueue();
|
||||
|
||||
const disposer = divFiber.signalDisposers.pop()!;
|
||||
disposer();
|
||||
|
||||
const callCountBefore = prepareUpdateCalls.filter((c) => c.tag === "div").length;
|
||||
color.value = "green";
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
queueMicrotask(() => resolve());
|
||||
});
|
||||
|
||||
expect(prepareUpdateCalls.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, prepareUpdateCalls, 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<string, string, unknown>,
|
||||
{},
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
queueMicrotask(() => resolve());
|
||||
});
|
||||
resetUpdateQueue();
|
||||
prepareUpdateCalls.length = 0;
|
||||
commitUpdateCalls.length = 0;
|
||||
|
||||
batch(() => {
|
||||
color.value = "blue";
|
||||
size.value = "large";
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
queueMicrotask(() => resolve());
|
||||
});
|
||||
|
||||
expect(prepareUpdateCalls.filter((c) => c.tag === "div").length).toBe(1);
|
||||
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, prepareUpdateCalls, 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 divPrepareCalls = prepareUpdateCalls.filter((c) => c.tag === "div");
|
||||
expect(divPrepareCalls.length).toBe(1);
|
||||
expect(divPrepareCalls[0]!.prevProps.color).toBe("red");
|
||||
expect(divPrepareCalls[0]!.nextProps.color).toBe("blue");
|
||||
|
||||
const divCommitCalls = commitUpdateCalls.filter((c) => c.tag === "div");
|
||||
expect(divCommitCalls.length).toBe(1);
|
||||
expect(divCommitCalls[0]!.payload).toEqual({ color: "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("prepareUpdate is called for changed props on re-render", () => {
|
||||
const { host, prepareUpdateCalls } = makeTrackingHost();
|
||||
const root = createHostRoot(host, {});
|
||||
root.render(h("span", { label: "a" }));
|
||||
root.render(h("span", { label: "b" }));
|
||||
|
||||
const spanPrepares = prepareUpdateCalls.filter((c) => c.tag === "span");
|
||||
expect(spanPrepares.length).toBe(1);
|
||||
expect(spanPrepares[0]!.prevProps.label).toBe("a");
|
||||
expect(spanPrepares[0]!.nextProps.label).toBe("b");
|
||||
});
|
||||
});
|
||||
|
||||
describe("prepareUpdate/commitUpdate optional", () => {
|
||||
it("host without prepareUpdate is a no-op on re-render", () => {
|
||||
const host: HostConfig<string, string, Record<string, unknown>> = {
|
||||
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<string, string, unknown>, {});
|
||||
|
||||
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<string, string, unknown>, {});
|
||||
|
||||
expect(divFiber.effect).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user