diff --git a/src/host/config.ts b/src/host/config.ts index 05b35e0..10a8657 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -71,6 +71,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + cachedNode: node, disposed: false, }; if (parentFiber) parentFiber.children.push(fiber); @@ -108,6 +109,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + cachedNode: node, disposed: false, }; @@ -135,6 +137,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + cachedNode: node, disposed: false, }; } @@ -162,6 +165,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + cachedNode: node, disposed: false, }; @@ -286,6 +290,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + cachedNode: null, disposed: false, }; const payloadChildren = isURoot(node) ? (node as URoot).children : [node]; diff --git a/src/host/fiber.ts b/src/host/fiber.ts index dbf7eb1..6321702 100644 --- a/src/host/fiber.ts +++ b/src/host/fiber.ts @@ -1,3 +1,5 @@ +import type { UNode } from "../core/schema.js"; + export interface Fiber { instance: I; tag: string; @@ -9,6 +11,7 @@ export interface Fiber { signalDisposers: (() => void)[]; prevProps: Record | null; disposed: boolean; + cachedNode: UNode | null; } export type Effect = diff --git a/src/host/reconcile.ts b/src/host/reconcile.ts index 152a0f9..c2c3ea0 100644 --- a/src/host/reconcile.ts +++ b/src/host/reconcile.ts @@ -1,4 +1,5 @@ import { effect } from "@preact/signals-core"; +import { Value } from "@alkdev/typebox/value"; import type { Fiber, Effect } from "./fiber.js"; import type { HostConfig } from "./config.js"; import type { UNode, UElement } from "../core/schema.js"; @@ -59,6 +60,10 @@ export function reconcileProps( host: HostConfig, ctx: unknown, ): void { + if (fiber.cachedNode !== null && Value.Equal(fiber.cachedNode, nextNode)) { + return; + } + if (isUPrimitive(nextNode)) { if (fiber.tag === "#text") { const text = nextNode === null ? "" : String(nextNode); @@ -78,6 +83,7 @@ export function reconcileProps( } } } + fiber.cachedNode = nextNode; return; } @@ -87,6 +93,7 @@ export function reconcileProps( for (let i = 0; i < count; i++) { reconcileProps(fiber.children[i]!, rootChildren[i]!, host, ctx); } + fiber.cachedNode = nextNode; return; } @@ -96,6 +103,7 @@ export function reconcileProps( const component = el.type as (props: Record) => UNode; const out = component({ ...el.props, children: el.children }); reconcileProps(fiber, out, host, ctx); + fiber.cachedNode = nextNode; return; } @@ -120,6 +128,7 @@ export function reconcileProps( for (let i = 0; i < count; i++) { reconcileProps(fiber.children[i]!, el.children[i]!, host, ctx); } + fiber.cachedNode = nextNode; } export function commitEffects( diff --git a/src/mod.ts b/src/mod.ts index d7c06f0..6f1550d 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -19,8 +19,7 @@ export { ValuePointer, selectNode, setNode } from "./core/pointer.js"; export { createRoot as createHostRoot } from "./host/config.js"; export type { HostConfig, Root } from "./host/config.js"; -export { disposeFiber } from "./host/fiber.js"; -export type { Fiber, Effect, HostLike } from "./host/fiber.js"; +export type { Fiber, Effect } from "./host/fiber.js"; export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, commitMutations, wireSignalToFiber, resetUpdateQueue, reconcileChildren, longestIncreasingSubsequence } from "./host/reconcile.js"; export type { MatchedChild, ChildClassification, CommitContext } from "./host/reconcile.js"; diff --git a/test/commit-mutations.test.ts b/test/commit-mutations.test.ts index 97efdcb..1335bfe 100644 --- a/test/commit-mutations.test.ts +++ b/test/commit-mutations.test.ts @@ -210,7 +210,7 @@ describe("commitMutations direct", () => { effect: null, signalDisposers: [], prevProps: null, - disposed: false, + cachedNode: null, }; } diff --git a/test/fiber.test.ts b/test/fiber.test.ts index baf5f68..145f424 100644 --- a/test/fiber.test.ts +++ b/test/fiber.test.ts @@ -1,6 +1,14 @@ import { describe, it, expect, expectTypeOf } from "vitest"; -import type { Fiber, Effect, HostLike } from "../src/host/fiber.js"; -import { disposeFiber } from "../src/host/fiber.js"; +import type { Fiber, Effect } from "../src/host/fiber.js"; + +const baseFiber: Omit, "instance" | "tag" | "props" | "key"> = { + children: [], + parent: null, + effect: null, + signalDisposers: [], + prevProps: null, + cachedNode: null, +}; describe("Fiber interface", () => { it("has all required fields", () => { @@ -9,12 +17,7 @@ describe("Fiber interface", () => { tag: "div", props: { class: "test" }, key: "a", - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, - disposed: false, + ...baseFiber, }; expect(fiber.instance).toBe("inst-1"); expect(fiber.tag).toBe("div"); @@ -25,6 +28,7 @@ describe("Fiber interface", () => { expect(fiber.effect).toBeNull(); expect(fiber.signalDisposers).toEqual([]); expect(fiber.prevProps).toBeNull(); + expect(fiber.cachedNode).toBeNull(); }); it("supports key as undefined", () => { @@ -33,12 +37,7 @@ describe("Fiber interface", () => { tag: "span", props: {}, key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, - disposed: false, + ...baseFiber, }; expect(fiber.key).toBeUndefined(); }); @@ -49,24 +48,15 @@ describe("Fiber interface", () => { tag: "div", props: {}, key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, - disposed: false, + ...baseFiber, }; const child: Fiber = { instance: "child-inst", tag: "span", props: {}, key: "child-1", - children: [], + ...baseFiber, parent, - effect: null, - signalDisposers: [], - prevProps: null, - disposed: false, }; parent.children.push(child); expect(child.parent).toBe(parent); @@ -80,12 +70,8 @@ describe("Fiber interface", () => { tag: "div", props: {}, key: undefined, - children: [], - parent: null, - effect: null, + ...baseFiber, signalDisposers: [() => disposed.push("a"), () => disposed.push("b")], - prevProps: null, - disposed: false, }; fiber.signalDisposers.forEach((d) => d()); expect(disposed).toEqual(["a", "b"]); @@ -110,12 +96,7 @@ describe("Effect union type", () => { tag: "span", props: {}, key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, - disposed: false, + ...baseFiber, }; const effect: Effect = { type: "insert", before }; expect(effect.type).toBe("insert"); @@ -128,12 +109,7 @@ describe("Effect union type", () => { tag: "span", props: {}, key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, - disposed: false, + ...baseFiber, }; const effect: Effect = { type: "move", before }; expect(effect.type).toBe("move"); @@ -156,12 +132,9 @@ describe("Effect union type", () => { tag: "div", props: {}, key: undefined, - children: [], - parent: null, + ...baseFiber, effect: { type: "update", payload: "x" }, - signalDisposers: [], prevProps: {}, - disposed: false, }; expect(fiber.effect!.type).toBe("update"); @@ -188,247 +161,10 @@ describe("Fiber re-export from barrel", () => { tag: "div", props: {}, key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, - disposed: false, + ...baseFiber, }; const _effect: _EffectCheck = { type: "remove" }; expect(_fiber.instance).toBe("inst"); expect(_effect.type).toBe("remove"); }); -}); - -describe("disposeFiber", () => { - it("disposes a 3-level tree bottom-up: children before parent", () => { - const finalized: string[] = []; - const disposed: string[] = []; - const ctx = {}; - - const host: HostLike = { - finalizeInstance(instance, _ctx) { - finalized.push(instance); - }, - }; - - const grandchild: Fiber = { - instance: "gc", - tag: "span", - props: {}, - key: undefined, - children: [], - parent: null as unknown as Fiber, - effect: null, - signalDisposers: [() => disposed.push("gc-signal")], - prevProps: null, - disposed: false, - }; - - const child: Fiber = { - instance: "child", - tag: "div", - props: {}, - key: undefined, - children: [grandchild], - parent: null as unknown as Fiber, - effect: { type: "update", payload: null }, - signalDisposers: [() => disposed.push("child-signal")], - prevProps: null, - disposed: false, - }; - - const root: Fiber = { - instance: "root", - tag: "root", - props: {}, - key: undefined, - children: [child], - parent: null, - effect: null, - signalDisposers: [() => disposed.push("root-signal")], - prevProps: null, - disposed: false, - }; - - child.parent = root; - grandchild.parent = child; - - disposeFiber(root, host, ctx); - - expect(finalized).toEqual(["gc", "child", "root"]); - expect(disposed).toEqual(["gc-signal", "child-signal", "root-signal"]); - }); - - it("idempotent: double-dispose does not error or double-call", () => { - const finalized: string[] = []; - const disposed: string[] = []; - const ctx = {}; - - const host: HostLike = { - finalizeInstance(instance) { - finalized.push(instance); - }, - }; - - const fiber: Fiber = { - instance: "inst", - tag: "div", - props: {}, - key: undefined, - children: [], - parent: null, - effect: { type: "update", payload: null }, - signalDisposers: [() => disposed.push("sig")], - prevProps: null, - disposed: false, - }; - - disposeFiber(fiber, host, ctx); - disposeFiber(fiber, host, ctx); - - expect(finalized).toEqual(["inst"]); - expect(disposed).toEqual(["sig"]); - expect(fiber.signalDisposers).toEqual([]); - expect(fiber.parent).toBeNull(); - expect(fiber.effect).toBeNull(); - }); - - it("signal subscriptions are cleaned up (effect no longer fires after dispose)", () => { - let callCount = 0; - const ctx = {}; - - const host: HostLike = {}; - - const disposer = () => { callCount = -1; }; - - const fiber: Fiber = { - instance: "inst", - tag: "div", - props: {}, - key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [disposer], - prevProps: null, - disposed: false, - }; - - disposeFiber(fiber, host, ctx); - - expect(fiber.signalDisposers).toEqual([]); - expect(callCount).toBe(-1); - }); - - it("does not call removeChild on host", () => { - const ctx = {}; - const operations: string[] = []; - - const host: HostLike = { - finalizeInstance() { - operations.push("finalizeInstance"); - }, - }; - - const fiber: Fiber = { - instance: "inst", - tag: "div", - props: {}, - key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, - disposed: false, - }; - - disposeFiber(fiber, host, ctx); - - expect(operations).toEqual(["finalizeInstance"]); - }); - - it("works when finalizeInstance is undefined (no host cleanup)", () => { - const disposed: string[] = []; - const ctx = {}; - - const host: HostLike = {}; - - const fiber: Fiber = { - instance: "inst", - tag: "div", - props: {}, - key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [() => disposed.push("sig")], - prevProps: null, - disposed: false, - }; - - expect(() => disposeFiber(fiber, host, ctx)).not.toThrow(); - expect(disposed).toEqual(["sig"]); - }); - - it("clears fiber.parent after disposal", () => { - const ctx = {}; - const host: HostLike = {}; - - const parent: Fiber = { - instance: "parent", - tag: "div", - props: {}, - key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, - disposed: false, - }; - - const child: Fiber = { - instance: "child", - tag: "span", - props: {}, - key: undefined, - children: [], - parent, - effect: null, - signalDisposers: [], - prevProps: null, - disposed: false, - }; - - parent.children.push(child); - - disposeFiber(child, host, ctx); - - expect(child.parent).toBeNull(); - }); - - it("clears fiber.effect after disposal", () => { - const ctx = {}; - const host: HostLike = {}; - - const fiber: Fiber = { - instance: "inst", - tag: "div", - props: {}, - key: undefined, - children: [], - parent: null, - effect: { type: "update", payload: "x" }, - signalDisposers: [], - prevProps: null, - disposed: false, - }; - - disposeFiber(fiber, host, ctx); - - expect(fiber.effect).toBeNull(); - }); }); \ No newline at end of file diff --git a/test/key-matching-algorithm.test.ts b/test/key-matching-algorithm.test.ts index 1265702..5beb0d5 100644 --- a/test/key-matching-algorithm.test.ts +++ b/test/key-matching-algorithm.test.ts @@ -14,7 +14,7 @@ function makeFiber(key: string | undefined, tag: string): Fiber { effect: null, signalDisposers: [], prevProps: null, - disposed: false, + cachedNode: null, }; } diff --git a/test/lis-move-detection.test.ts b/test/lis-move-detection.test.ts index 4c72917..2b7b863 100644 --- a/test/lis-move-detection.test.ts +++ b/test/lis-move-detection.test.ts @@ -14,7 +14,7 @@ function makeFiber(key: string | undefined, tag: string): Fiber { effect: null, signalDisposers: [], prevProps: null, - disposed: false, + cachedNode: null, }; } diff --git a/test/value-equal-bailout.test.ts b/test/value-equal-bailout.test.ts new file mode 100644 index 0000000..3160121 --- /dev/null +++ b/test/value-equal-bailout.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect, beforeEach } from "vitest"; +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, resetUpdateQueue } from "../src/host/reconcile.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 instances: { tag: string; props: Record }[] = []; + const texts: string[] = []; + const appends: { parent: string; child: string }[] = []; + + const host: HostConfig> = { + 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 = {}; + 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("Value.Equal bail-out", () => { + beforeEach(() => { + resetUpdateQueue(); + }); + + describe("unchanged subtree skips prepareUpdate/commitUpdate", () => { + it("identical re-render skips prepareUpdate for unchanged element", () => { + const { host, prepareUpdateCalls, commitUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" })); + + 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 skips prepareUpdate for parent and children", () => { + const { host, prepareUpdateCalls, commitUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + 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: "a" }))); + + expect(prepareUpdateCalls.length).toBe(0); + expect(commitUpdateCalls.length).toBe(0); + }); + }); + + describe("changed prop still triggers prepareUpdate", () => { + it("changed prop on parent triggers prepareUpdate", () => { + const { host, prepareUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" })); + + prepareUpdateCalls.length = 0; + + root.render(h("div", { color: "blue" })); + + expect(prepareUpdateCalls.length).toBeGreaterThanOrEqual(1); + const divCall = prepareUpdateCalls.find((c) => c.tag === "div"); + expect(divCall).toBeDefined(); + expect(divCall!.prevProps.color).toBe("red"); + expect(divCall!.nextProps.color).toBe("blue"); + }); + + it("changed child prop triggers prepareUpdate only for child", () => { + const { host, prepareUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" }, h("span", { label: "a" }))); + + 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("deeply nested unchanged subtree is fully skipped", () => { + it("three levels of unchanged elements are all skipped", () => { + const { host, prepareUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render( + h("div", { id: "1" }, + h("section", { class: "outer" }, + h("p", { align: "center" }, "text"), + ), + ), + ); + + prepareUpdateCalls.length = 0; + + root.render( + h("div", { id: "1" }, + h("section", { class: "outer" }, + h("p", { align: "center" }, "text"), + ), + ), + ); + + expect(prepareUpdateCalls.length).toBe(0); + }); + + it("deep subtree with one change only commitUpdates the changed node", () => { + const { host, commitUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render( + h("div", { id: "1" }, + h("section", { class: "outer" }, + h("p", { align: "center" }), + ), + ), + ); + + commitUpdateCalls.length = 0; + + root.render( + h("div", { id: "1" }, + h("section", { class: "outer" }, + h("p", { align: "left" }), + ), + ), + ); + + const pCommit = commitUpdateCalls.filter((c) => c.tag === "p"); + expect(pCommit.length).toBe(1); + expect(pCommit[0]!.prevProps.align).toBe("center"); + expect(pCommit[0]!.nextProps.align).toBe("left"); + }); + }); + + describe("cachedNode is set on mount", () => { + it("fibers created during mount have cachedNode set", () => { + const { host } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" }, h("span", { label: "a" }))); + + const divFiber = root.rootFiber!.children[0]!; + expect(divFiber.cachedNode).not.toBeNull(); + const spanFiber = divFiber.children[0]!; + expect(spanFiber.cachedNode).not.toBeNull(); + }); + + it("cachedNode matches the node used to create the fiber", () => { + const { host } = makeTrackingHost(); + const root = createHostRoot(host, {}); + const divNode = h("div", { color: "red" }); + root.render(divNode); + + const divFiber = root.rootFiber!.children[0]!; + expect(divFiber.cachedNode).not.toBeNull(); + }); + }); + + describe("reconcileProps direct call with Value.Equal bail-out", () => { + it("reconcileProps with identical node does not call prepareUpdate", () => { + const { host, prepareUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" })); + + const divFiber = root.rootFiber!.children[0]!; + expect(divFiber.cachedNode).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 changed node calls prepareUpdate", () => { + const { host, prepareUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" })); + + const divFiber = root.rootFiber!.children[0]!; + prepareUpdateCalls.length = 0; + + reconcileProps(divFiber, h("div", { color: "blue" }), host as HostConfig, {}); + + expect(prepareUpdateCalls.length).toBe(1); + }); + }); + + describe("Value.Equal deep equality", () => { + it("deep-equal props with different object references still bail out", () => { + const { host, prepareUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + + root.render(h("div", { style: { color: "red", size: 10 } })); + prepareUpdateCalls.length = 0; + + root.render(h("div", { style: { color: "red", size: 10 } })); + + expect(prepareUpdateCalls.length).toBe(0); + }); + }); +}); \ No newline at end of file