diff --git a/src/host/config.ts b/src/host/config.ts index 7f8c339..b5ba68e 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -70,6 +70,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + cachedNode: node, }; if (parentFiber) parentFiber.children.push(fiber); return fiber; @@ -106,6 +107,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + cachedNode: node, }; for (const child of el.children) { @@ -132,6 +134,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + cachedNode: node, }; } @@ -158,6 +161,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + cachedNode: node, }; for (const child of el.children) { @@ -281,6 +285,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + cachedNode: 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 87bacf2..a314fbc 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; @@ -8,6 +10,7 @@ export interface Fiber { effect: Effect | null; signalDisposers: (() => void)[]; prevProps: Record | null; + 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/test/commit-mutations.test.ts b/test/commit-mutations.test.ts index a4d6b0f..1335bfe 100644 --- a/test/commit-mutations.test.ts +++ b/test/commit-mutations.test.ts @@ -210,6 +210,7 @@ describe("commitMutations direct", () => { effect: null, signalDisposers: [], prevProps: null, + cachedNode: null, }; } diff --git a/test/fiber.test.ts b/test/fiber.test.ts index a2ca9e6..145f424 100644 --- a/test/fiber.test.ts +++ b/test/fiber.test.ts @@ -1,6 +1,15 @@ import { describe, it, expect, expectTypeOf } from "vitest"; 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", () => { const fiber: Fiber = { @@ -8,11 +17,7 @@ describe("Fiber interface", () => { tag: "div", props: { class: "test" }, key: "a", - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, + ...baseFiber, }; expect(fiber.instance).toBe("inst-1"); expect(fiber.tag).toBe("div"); @@ -23,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", () => { @@ -31,11 +37,7 @@ describe("Fiber interface", () => { tag: "span", props: {}, key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, + ...baseFiber, }; expect(fiber.key).toBeUndefined(); }); @@ -46,22 +48,15 @@ describe("Fiber interface", () => { tag: "div", props: {}, key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, + ...baseFiber, }; const child: Fiber = { instance: "child-inst", tag: "span", props: {}, key: "child-1", - children: [], + ...baseFiber, parent, - effect: null, - signalDisposers: [], - prevProps: null, }; parent.children.push(child); expect(child.parent).toBe(parent); @@ -75,11 +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, }; fiber.signalDisposers.forEach((d) => d()); expect(disposed).toEqual(["a", "b"]); @@ -104,11 +96,7 @@ describe("Effect union type", () => { tag: "span", props: {}, key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, + ...baseFiber, }; const effect: Effect = { type: "insert", before }; expect(effect.type).toBe("insert"); @@ -121,11 +109,7 @@ describe("Effect union type", () => { tag: "span", props: {}, key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, + ...baseFiber, }; const effect: Effect = { type: "move", before }; expect(effect.type).toBe("move"); @@ -148,10 +132,8 @@ describe("Effect union type", () => { tag: "div", props: {}, key: undefined, - children: [], - parent: null, + ...baseFiber, effect: { type: "update", payload: "x" }, - signalDisposers: [], prevProps: {}, }; expect(fiber.effect!.type).toBe("update"); @@ -179,11 +161,7 @@ describe("Fiber re-export from barrel", () => { tag: "div", props: {}, key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, + ...baseFiber, }; const _effect: _EffectCheck = { type: "remove" }; expect(_fiber.instance).toBe("inst"); diff --git a/test/key-matching-algorithm.test.ts b/test/key-matching-algorithm.test.ts index b5c110c..5beb0d5 100644 --- a/test/key-matching-algorithm.test.ts +++ b/test/key-matching-algorithm.test.ts @@ -14,6 +14,7 @@ function makeFiber(key: string | undefined, tag: string): Fiber { effect: null, signalDisposers: [], prevProps: null, + cachedNode: null, }; } diff --git a/test/lis-move-detection.test.ts b/test/lis-move-detection.test.ts index b343201..2b7b863 100644 --- a/test/lis-move-detection.test.ts +++ b/test/lis-move-detection.test.ts @@ -14,6 +14,7 @@ function makeFiber(key: string | undefined, tag: string): Fiber { effect: null, signalDisposers: [], prevProps: null, + 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