diff --git a/src/host/config.ts b/src/host/config.ts index 606af1c..49a716f 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -74,6 +74,7 @@ export function createRoot( prevProps: null, cachedNode: node, disposed: false, + hash: null, }; if (parentFiber) parentFiber.children.push(fiber); return fiber; @@ -112,6 +113,7 @@ export function createRoot( prevProps: null, cachedNode: node, disposed: false, + hash: null, }; for (const child of el.children) { @@ -140,6 +142,7 @@ export function createRoot( prevProps: null, cachedNode: node, disposed: false, + hash: null, }; } @@ -168,6 +171,7 @@ export function createRoot( prevProps: null, cachedNode: node, disposed: false, + hash: null, }; for (const child of el.children) { @@ -293,6 +297,7 @@ export function createRoot( prevProps: null, cachedNode: null, disposed: false, + hash: null, }; const payloadChildren = isURoot(node) ? (node as URoot).children : [node]; for (const child of payloadChildren) { diff --git a/src/host/fiber.ts b/src/host/fiber.ts index 6321702..81892ae 100644 --- a/src/host/fiber.ts +++ b/src/host/fiber.ts @@ -12,6 +12,7 @@ export interface Fiber { prevProps: Record | null; disposed: boolean; cachedNode: UNode | null; + hash: bigint | null; } export type Effect = diff --git a/src/host/reconcile.ts b/src/host/reconcile.ts index 8d57c91..cc85711 100644 --- a/src/host/reconcile.ts +++ b/src/host/reconcile.ts @@ -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( host: HostConfig, 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( fiber.effect = null; } +export function commitHashes(fiber: Fiber): void { + try { + fiber.hash = Value.Hash(fiber.cachedNode); + } catch { + fiber.hash = null; + } + for (const child of fiber.children) { + commitHashes(child); + } +} + export function wireSignalToFiber( fiber: Fiber, signalGetter: () => UNode, @@ -436,6 +457,7 @@ export function commitMutations( // Phase 3: Updates — top-down (parent before child) via commitEffects commitEffects(parentFiber, host as HostConfig, ctx); + commitHashes(parentFiber); } function findNextStayingFiber( diff --git a/src/mod.ts b/src/mod.ts index 6f1550d..dcfb63f 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -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"; diff --git a/test/value-hash-detection.test.ts b/test/value-hash-detection.test.ts new file mode 100644 index 0000000..4db3356 --- /dev/null +++ b/test/value-hash-detection.test.ts @@ -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; nextProps: Record }[] = []; + const commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record; nextProps: Record }[] = []; + + const host: HostConfig> = { + 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 = {}; + 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, + {}, + ); + + prepareUpdateCalls.length = 0; + commitUpdateCalls.length = 0; + + s.value = "blue"; + + await new Promise((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((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, {}); + 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, {}); + + 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, {}); + + 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); + }); + }); +}); \ No newline at end of file