Merge feat/value-hash-detection: add Value.Hash O(1) change detection with commitHashes (clean merge, no Value.Diff overlap)

This commit is contained in:
2026-05-18 17:40:13 +00:00
parent 6d704f59e0
commit 27c1f2671b
4 changed files with 36 additions and 50 deletions

2
.gitignore vendored
View File

@@ -7,4 +7,4 @@ dist/
.idea/ .idea/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
coverage/ coverage/.worktrees/

View File

@@ -81,15 +81,15 @@ describe("Root.render() re-renderable", () => {
expect(divFiber.props.class).toBe("b"); expect(divFiber.props.class).toBe("b");
}); });
it("prepareUpdate is called for changed props on re-render", () => { it("commitUpdate is called for changed props on re-render", () => {
const { host, prepareUpdateCalls } = makeHost(); const { host, commitUpdateCalls } = makeHost();
const root = createHostRoot(host, {}); const root = createHostRoot(host, {});
root.render(h("div", { class: "old" }, "hello")); root.render(h("div", { class: "old" }, "hello"));
prepareUpdateCalls.length = 0; commitUpdateCalls.length = 0;
root.render(h("div", { class: "new" }, "hello")); root.render(h("div", { class: "new" }, "hello"));
expect(prepareUpdateCalls.length).toBeGreaterThanOrEqual(1); expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1);
const divCall = prepareUpdateCalls.find((c) => c.tag === "div"); const divCall = commitUpdateCalls.find((c) => c.tag === "div");
expect(divCall).toBeDefined(); expect(divCall).toBeDefined();
expect(divCall!.prevProps.class).toBe("old"); expect(divCall!.prevProps.class).toBe("old");
expect(divCall!.nextProps.class).toBe("new"); expect(divCall!.nextProps.class).toBe("new");

View File

@@ -60,8 +60,8 @@ describe("signal-driven-updates", () => {
}); });
describe("wireSignalToFiber", () => { describe("wireSignalToFiber", () => {
it("signal change triggers prepareUpdate + commitUpdate", async () => { it("signal change triggers commitUpdate", async () => {
const { host, prepareUpdateCalls, commitUpdateCalls } = makeTrackingHost(); const { host, commitUpdateCalls } = makeTrackingHost();
const color = signal("red"); const color = signal("red");
const root = createHostRoot(host, {}); const root = createHostRoot(host, {});
@@ -77,8 +77,7 @@ describe("signal-driven-updates", () => {
{}, {},
); );
expect(prepareUpdateCalls.length).toBe(0); commitUpdateCalls.length = 0;
expect(commitUpdateCalls.length).toBe(0);
color.value = "blue"; color.value = "blue";
@@ -86,16 +85,9 @@ describe("signal-driven-updates", () => {
queueMicrotask(() => 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); expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1);
const lastCommit = commitUpdateCalls[commitUpdateCalls.length - 1]!; const lastCommit = commitUpdateCalls[commitUpdateCalls.length - 1]!;
expect(lastCommit.instance).toBe(divFiber.instance); expect(lastCommit.instance).toBe(divFiber.instance);
expect(lastCommit.payload).toEqual({ color: "blue" });
}); });
it("signal effect disposer stored in fiber.signalDisposers", () => { it("signal effect disposer stored in fiber.signalDisposers", () => {
@@ -120,7 +112,7 @@ describe("signal-driven-updates", () => {
}); });
it("disposing signal via signalDisposers stops updates", async () => { it("disposing signal via signalDisposers stops updates", async () => {
const { host, prepareUpdateCalls } = makeTrackingHost(); const { host, commitUpdateCalls } = makeTrackingHost();
const color = signal("red"); const color = signal("red");
const root = createHostRoot(host, {}); const root = createHostRoot(host, {});
@@ -142,20 +134,20 @@ describe("signal-driven-updates", () => {
const disposer = divFiber.signalDisposers.pop()!; const disposer = divFiber.signalDisposers.pop()!;
disposer(); disposer();
const callCountBefore = prepareUpdateCalls.filter((c) => c.tag === "div").length; const callCountBefore = commitUpdateCalls.filter((c) => c.tag === "div").length;
color.value = "green"; color.value = "green";
await new Promise<void>((resolve) => { await new Promise<void>((resolve) => {
queueMicrotask(() => resolve()); queueMicrotask(() => resolve());
}); });
expect(prepareUpdateCalls.filter((c) => c.tag === "div").length).toBe(callCountBefore); expect(commitUpdateCalls.filter((c) => c.tag === "div").length).toBe(callCountBefore);
}); });
}); });
describe("batch of signal changes", () => { describe("batch of signal changes", () => {
it("batch of signal changes results in single reconciliation pass", async () => { it("batch of signal changes results in single reconciliation pass", async () => {
const { host, prepareUpdateCalls, commitUpdateCalls } = makeTrackingHost(); const { host, commitUpdateCalls } = makeTrackingHost();
const color = signal("red"); const color = signal("red");
const size = signal("small"); const size = signal("small");
@@ -175,7 +167,6 @@ describe("signal-driven-updates", () => {
queueMicrotask(() => resolve()); queueMicrotask(() => resolve());
}); });
resetUpdateQueue(); resetUpdateQueue();
prepareUpdateCalls.length = 0;
commitUpdateCalls.length = 0; commitUpdateCalls.length = 0;
batch(() => { batch(() => {
@@ -187,14 +178,13 @@ describe("signal-driven-updates", () => {
queueMicrotask(() => resolve()); queueMicrotask(() => resolve());
}); });
expect(prepareUpdateCalls.filter((c) => c.tag === "div").length).toBe(1);
expect(commitUpdateCalls.filter((c) => c.tag === "div").length).toBe(1); expect(commitUpdateCalls.filter((c) => c.tag === "div").length).toBe(1);
}); });
}); });
describe("render() re-renderable", () => { describe("render() re-renderable", () => {
it("second render reconciles props against existing fiber tree", () => { it("second render reconciles props against existing fiber tree", () => {
const { host, prepareUpdateCalls, commitUpdateCalls, instances } = makeTrackingHost(); const { host, commitUpdateCalls, instances } = makeTrackingHost();
const root = createHostRoot(host, {}); const root = createHostRoot(host, {});
root.render(h("div", { color: "red" }, "hello")); root.render(h("div", { color: "red" }, "hello"));
@@ -205,14 +195,10 @@ describe("signal-driven-updates", () => {
expect(instances.length).toBe(instanceCountBefore); 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"); const divCommitCalls = commitUpdateCalls.filter((c) => c.tag === "div");
expect(divCommitCalls.length).toBe(1); expect(divCommitCalls.length).toBe(1);
expect(divCommitCalls[0]!.payload).toEqual({ color: "blue" }); expect(divCommitCalls[0]!.prevProps.color).toBe("red");
expect(divCommitCalls[0]!.nextProps.color).toBe("blue");
}); });
it("second render does not create duplicate instances", () => { it("second render does not create duplicate instances", () => {
@@ -225,16 +211,16 @@ describe("signal-driven-updates", () => {
expect(instances.length).toBe(1); expect(instances.length).toBe(1);
}); });
it("prepareUpdate is called for changed props on re-render", () => { it("commitUpdate is called for changed props on re-render", () => {
const { host, prepareUpdateCalls } = makeTrackingHost(); const { host, commitUpdateCalls } = makeTrackingHost();
const root = createHostRoot(host, {}); const root = createHostRoot(host, {});
root.render(h("span", { label: "a" })); root.render(h("span", { label: "a" }));
root.render(h("span", { label: "b" })); root.render(h("span", { label: "b" }));
const spanPrepares = prepareUpdateCalls.filter((c) => c.tag === "span"); const spanCommits = commitUpdateCalls.filter((c) => c.tag === "span");
expect(spanPrepares.length).toBe(1); expect(spanCommits.length).toBe(1);
expect(spanPrepares[0]!.prevProps.label).toBe("a"); expect(spanCommits[0]!.prevProps.label).toBe("a");
expect(spanPrepares[0]!.nextProps.label).toBe("b"); expect(spanCommits[0]!.nextProps.label).toBe("b");
}); });
}); });

View File

@@ -88,33 +88,33 @@ describe("Value.Equal bail-out", () => {
}); });
}); });
describe("changed prop still triggers prepareUpdate", () => { describe("changed prop still triggers update", () => {
it("changed prop on parent triggers prepareUpdate", () => { it("changed prop on parent triggers commitUpdate", () => {
const { host, prepareUpdateCalls } = makeTrackingHost(); const { host, commitUpdateCalls } = makeTrackingHost();
const root = createHostRoot(host, {}); const root = createHostRoot(host, {});
root.render(h("div", { color: "red" })); root.render(h("div", { color: "red" }));
prepareUpdateCalls.length = 0; commitUpdateCalls.length = 0;
root.render(h("div", { color: "blue" })); root.render(h("div", { color: "blue" }));
expect(prepareUpdateCalls.length).toBeGreaterThanOrEqual(1); expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1);
const divCall = prepareUpdateCalls.find((c) => c.tag === "div"); const divCall = commitUpdateCalls.find((c) => c.tag === "div");
expect(divCall).toBeDefined(); expect(divCall).toBeDefined();
expect(divCall!.prevProps.color).toBe("red"); expect(divCall!.prevProps.color).toBe("red");
expect(divCall!.nextProps.color).toBe("blue"); expect(divCall!.nextProps.color).toBe("blue");
}); });
it("changed child prop triggers prepareUpdate only for child", () => { it("changed child prop triggers commitUpdate only for child", () => {
const { host, prepareUpdateCalls } = makeTrackingHost(); const { host, commitUpdateCalls } = makeTrackingHost();
const root = createHostRoot(host, {}); const root = createHostRoot(host, {});
root.render(h("div", { color: "red" }, h("span", { label: "a" }))); root.render(h("div", { color: "red" }, h("span", { label: "a" })));
prepareUpdateCalls.length = 0; commitUpdateCalls.length = 0;
root.render(h("div", { color: "red" }, h("span", { label: "b" }))); root.render(h("div", { color: "red" }, h("span", { label: "b" })));
const spanCalls = prepareUpdateCalls.filter((c) => c.tag === "span"); const spanCalls = commitUpdateCalls.filter((c) => c.tag === "span");
expect(spanCalls.length).toBe(1); expect(spanCalls.length).toBe(1);
expect(spanCalls[0]!.prevProps.label).toBe("a"); expect(spanCalls[0]!.prevProps.label).toBe("a");
expect(spanCalls[0]!.nextProps.label).toBe("b"); expect(spanCalls[0]!.nextProps.label).toBe("b");
@@ -214,17 +214,17 @@ describe("Value.Equal bail-out", () => {
expect(divFiber.effect).toBeNull(); expect(divFiber.effect).toBeNull();
}); });
it("reconcileProps with changed node calls prepareUpdate", () => { it("reconcileProps with changed node produces an update effect", () => {
const { host, prepareUpdateCalls } = makeTrackingHost(); const { host } = makeTrackingHost();
const root = createHostRoot(host, {}); const root = createHostRoot(host, {});
root.render(h("div", { color: "red" })); root.render(h("div", { color: "red" }));
const divFiber = root.rootFiber!.children[0]!; const divFiber = root.rootFiber!.children[0]!;
prepareUpdateCalls.length = 0;
reconcileProps(divFiber, h("div", { color: "blue" }), host as HostConfig<string, string, unknown>, {}); reconcileProps(divFiber, h("div", { color: "blue" }), host as HostConfig<string, string, unknown>, {});
expect(prepareUpdateCalls.length).toBe(1); expect(divFiber.effect).not.toBeNull();
expect(divFiber.props.color).toBe("blue");
}); });
}); });