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:
2026-05-18 17:38:27 +00:00
parent 84498f6b58
commit 6d704f59e0
5 changed files with 317 additions and 1 deletions

View File

@@ -74,6 +74,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
prevProps: null,
cachedNode: node,
disposed: false,
hash: null,
};
if (parentFiber) parentFiber.children.push(fiber);
return fiber;
@@ -112,6 +113,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
prevProps: null,
cachedNode: node,
disposed: false,
hash: null,
};
for (const child of el.children) {
@@ -140,6 +142,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
prevProps: null,
cachedNode: node,
disposed: false,
hash: null,
};
}
@@ -168,6 +171,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
prevProps: null,
cachedNode: node,
disposed: false,
hash: null,
};
for (const child of el.children) {
@@ -293,6 +297,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
prevProps: null,
cachedNode: null,
disposed: false,
hash: null,
};
const payloadChildren = isURoot(node) ? (node as URoot).children : [node];
for (const child of payloadChildren) {

View File

@@ -12,6 +12,7 @@ export interface Fiber<I> {
prevProps: Record<string, unknown> | null;
disposed: boolean;
cachedNode: UNode | null;
hash: bigint | null;
}
export type Effect<I> =

View File

@@ -51,6 +51,7 @@ export function flushUpdates(
if (!seen.has(root)) {
seen.add(root);
commitEffects(root, host, ctx);
commitHashes(root);
}
}
}
@@ -61,6 +62,15 @@ export function reconcileProps<I>(
host: HostConfig<string, I, unknown>,
ctx: unknown,
): void {
if (fiber.hash !== null) {
try {
const nextHash = Value.Hash(nextNode);
if (fiber.hash === nextHash) return;
} catch {
// Value.Hash may throw on unsupported values; fall through
}
}
if (fiber.cachedNode !== null && Value.Equal(fiber.cachedNode, nextNode)) {
return;
}
@@ -154,6 +164,17 @@ export function commitEffects<I>(
fiber.effect = null;
}
export function commitHashes<I>(fiber: Fiber<I>): void {
try {
fiber.hash = Value.Hash(fiber.cachedNode);
} catch {
fiber.hash = null;
}
for (const child of fiber.children) {
commitHashes(child);
}
}
export function wireSignalToFiber<I>(
fiber: Fiber<I>,
signalGetter: () => UNode,
@@ -436,6 +457,7 @@ export function commitMutations<I>(
// Phase 3: Updates — top-down (parent before child) via commitEffects
commitEffects(parentFiber, host as HostConfig<string, I, unknown>, ctx);
commitHashes(parentFiber);
}
function findNextStayingFiber<I>(

View File

@@ -21,7 +21,7 @@ export type { HostConfig, Root } from "./host/config.js";
export type { Fiber, Effect } from "./host/fiber.js";
export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, commitMutations, wireSignalToFiber, resetUpdateQueue, reconcileChildren, longestIncreasingSubsequence } from "./host/reconcile.js";
export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, commitHashes, commitMutations, wireSignalToFiber, resetUpdateQueue, reconcileChildren, longestIncreasingSubsequence } from "./host/reconcile.js";
export type { MatchedChild, ChildClassification, CommitContext } from "./host/reconcile.js";
export { TransformRegistry, childCtx, matchesSchema, ctx as transformCtx } from "./transform/registry.js";

View 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);
});
});
});