feat: add Value.Hash O(1) change detection before Value.Equal in reconciler
Adds hash field to Fiber<I> for caching FNV-1a hash after each commit. Hash comparison runs before Value.Equal in reconcileProps for fast bail-out on unchanged subtrees. Hashes computed during commit phase only (outside reactive computations) via commitHashes after commitEffects.
This commit is contained in:
288
test/value-hash-detection.test.ts
Normal file
288
test/value-hash-detection.test.ts
Normal file
@@ -0,0 +1,288 @@
|
||||
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<string, unknown>; nextProps: Record<string, unknown> }[] = [];
|
||||
const commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[] = [];
|
||||
|
||||
const host: HostConfig<string, string, Record<string, unknown>> = {
|
||||
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<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: 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 prepareUpdate", () => {
|
||||
const { host, prepareUpdateCalls } = makeTrackingHost();
|
||||
const root = createHostRoot(host, {});
|
||||
root.render(h("div", { color: "red" }));
|
||||
|
||||
commitHashes(root.rootFiber!);
|
||||
|
||||
prepareUpdateCalls.length = 0;
|
||||
|
||||
root.render(h("div", { color: "blue" }));
|
||||
|
||||
expect(prepareUpdateCalls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it("hash mismatch for child triggers prepareUpdate for child only", () => {
|
||||
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: "b" })));
|
||||
|
||||
const spanCalls = prepareUpdateCalls.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, prepareUpdateCalls, 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<string, string, unknown>,
|
||||
{},
|
||||
);
|
||||
|
||||
prepareUpdateCalls.length = 0;
|
||||
commitUpdateCalls.length = 0;
|
||||
|
||||
s.value = "blue";
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
queueMicrotask(() => resolve());
|
||||
});
|
||||
|
||||
expect(prepareUpdateCalls.length).toBeGreaterThanOrEqual(1);
|
||||
expect(divFiber.props.color).toBe("blue");
|
||||
|
||||
const newHash = divFiber.hash;
|
||||
expect(newHash).not.toBeNull();
|
||||
|
||||
prepareUpdateCalls.length = 0;
|
||||
commitUpdateCalls.length = 0;
|
||||
|
||||
s.value = "blue";
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
queueMicrotask(() => resolve());
|
||||
});
|
||||
|
||||
expect(prepareUpdateCalls.length).toBe(0);
|
||||
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<string, string, unknown>, {});
|
||||
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<string, string, unknown>, {});
|
||||
|
||||
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<string, string, unknown>, {});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user