diff --git a/src/host/config.ts b/src/host/config.ts index af58b1f..a8e33d5 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -2,6 +2,7 @@ import type { UNode, UElement, URoot, ComponentFn, UComponent } from "../core/sc import { isURoot, isUPrimitive } from "../core/schema.js"; import { Context } from "../core/context.js"; import type { Fiber } from "./fiber.js"; +import { reconcileProps, commitEffects } from "./reconcile.js"; export interface HostConfig { name: string; @@ -122,6 +123,19 @@ export function createRoot( context: rootContext, rootFiber: null, render(node: UNode) { + if (this.rootFiber) { + const payloadChildren = isURoot(node) ? (node as URoot).children : [node]; + for (let i = 0; i < payloadChildren.length; i++) { + const childFiber = this.rootFiber.children[i]; + if (childFiber) { + reconcileProps(childFiber, payloadChildren[i]!, host as HostConfig, ctx as unknown); + } + } + commitEffects(this.rootFiber, host as HostConfig, ctx as unknown); + host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length }); + return; + } + const root: Fiber = { instance: undefined as unknown as Instance, tag: "#root", diff --git a/src/host/reconcile.ts b/src/host/reconcile.ts new file mode 100644 index 0000000..a740448 --- /dev/null +++ b/src/host/reconcile.ts @@ -0,0 +1,157 @@ +import { effect } from "@preact/signals-core"; +import type { Fiber, Effect } from "./fiber.js"; +import type { HostConfig } from "./config.js"; +import type { UNode, UElement } from "../core/schema.js"; +import { isUPrimitive, isURoot } from "../core/schema.js"; + +interface PendingUpdate { + fiber: Fiber; + nextNode: UNode; +} + +let pendingUpdates: PendingUpdate[] = []; +let flushScheduled = false; + +export function scheduleUpdate( + fiber: Fiber, + nextNode: UNode, + host: HostConfig, + ctx: unknown, +): void { + pendingUpdates.push({ fiber: fiber as Fiber, nextNode }); + if (!flushScheduled) { + flushScheduled = true; + queueMicrotask(() => flushUpdates(host as HostConfig, ctx)); + } +} + +export function flushUpdates( + host: HostConfig, + ctx: unknown, +): void { + flushScheduled = false; + const updates = pendingUpdates.splice(0); + + for (const { fiber, nextNode } of updates) { + reconcileProps(fiber, nextNode, host, ctx); + } + + const seen = new Set>(); + for (const { fiber } of updates) { + let root: Fiber | null = fiber; + while (root.parent) root = root.parent; + if (!seen.has(root)) { + seen.add(root); + commitEffects(root, host, ctx); + } + } +} + +export function reconcileProps( + fiber: Fiber, + nextNode: UNode, + host: HostConfig, + ctx: unknown, +): void { + if (isUPrimitive(nextNode)) { + if (fiber.tag === "#text") { + const text = nextNode === null ? "" : String(nextNode); + if (fiber.props.text !== text) { + const nextProps = { text }; + const payload = host.prepareUpdate?.( + fiber.instance, + fiber.tag as never, + fiber.props, + nextProps, + ctx as never, + ); + if (payload !== null && payload !== undefined) { + fiber.effect = { type: "update", payload }; + fiber.prevProps = { ...fiber.props }; + fiber.props = nextProps; + } + } + } + return; + } + + if (isURoot(nextNode)) { + const rootChildren = nextNode.children; + const count = Math.min(fiber.children.length, rootChildren.length); + for (let i = 0; i < count; i++) { + reconcileProps(fiber.children[i]!, rootChildren[i]!, host, ctx); + } + return; + } + + const el = nextNode as UElement; + + if (typeof el.type === "function") { + const component = el.type as (props: Record) => UNode; + const out = component({ ...el.props, children: el.children }); + reconcileProps(fiber, out, host, ctx); + return; + } + + if (fiber.tag !== "#text" && fiber.tag !== el.type) return; + + const nextProps = el.props as Record; + const payload = host.prepareUpdate?.( + fiber.instance, + fiber.tag as never, + fiber.props, + nextProps, + ctx as never, + ); + + if (payload !== null && payload !== undefined) { + fiber.effect = { type: "update", payload }; + fiber.prevProps = { ...fiber.props }; + fiber.props = nextProps; + } + + const count = Math.min(fiber.children.length, el.children.length); + for (let i = 0; i < count; i++) { + reconcileProps(fiber.children[i]!, el.children[i]!, host, ctx); + } +} + +export function commitEffects( + fiber: Fiber, + host: HostConfig, + ctx: unknown, +): void { + if (fiber.effect?.type === "update") { + const updateEffect = fiber.effect as Effect & { type: "update"; payload: unknown }; + host.commitUpdate?.( + fiber.instance, + updateEffect.payload, + fiber.tag as never, + fiber.prevProps ?? {}, + fiber.props, + ctx as never, + ); + } + for (const child of fiber.children) { + commitEffects(child, host, ctx); + } + fiber.effect = null; +} + +export function wireSignalToFiber( + fiber: Fiber, + signalGetter: () => UNode, + host: HostConfig, + ctx: unknown, +): void { + const disposer = effect(() => { + const nextNode = signalGetter(); + scheduleUpdate(fiber, nextNode, host, ctx); + }); + fiber.signalDisposers.push(disposer); +} + +export function resetUpdateQueue(): void { + pendingUpdates = []; + flushScheduled = false; +} \ No newline at end of file diff --git a/src/mod.ts b/src/mod.ts index 2af5285..748ba33 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -21,5 +21,7 @@ export type { HostConfig, Root } from "./host/config.js"; export type { Fiber, Effect } from "./host/fiber.js"; +export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, wireSignalToFiber, resetUpdateQueue } from "./host/reconcile.js"; + export { TransformRegistry, childCtx, matchesSchema, ctx as transformCtx } from "./transform/registry.js"; export type { TransformContext, TransformFn, TransformRule } from "./transform/registry.js"; \ No newline at end of file diff --git a/test/signal-driven-updates.test.ts b/test/signal-driven-updates.test.ts new file mode 100644 index 0000000..d2d60f9 --- /dev/null +++ b/test/signal-driven-updates.test.ts @@ -0,0 +1,299 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { h } from "../src/core/h.js"; +import { signal, batch } from "../src/core/reactive.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 { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, wireSignalToFiber, 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("signal-driven-updates", () => { + beforeEach(() => { + resetUpdateQueue(); + }); + + describe("wireSignalToFiber", () => { + it("signal change triggers prepareUpdate + commitUpdate", async () => { + const { host, prepareUpdateCalls, commitUpdateCalls } = makeTrackingHost(); + const color = signal("red"); + + const root = createHostRoot(host, {}); + root.render(h("div", { color: color.value })); + + const divFiber = root.rootFiber!.children[0]!; + expect(divFiber.tag).toBe("div"); + + wireSignalToFiber( + divFiber, + () => h("div", { color: color.value }), + host as HostConfig, + {}, + ); + + expect(prepareUpdateCalls.length).toBe(0); + expect(commitUpdateCalls.length).toBe(0); + + color.value = "blue"; + + await new Promise((resolve) => { + queueMicrotask(() => resolve()); + }); + + expect(prepareUpdateCalls.length).toBeGreaterThanOrEqual(1); + const lastPrepare = prepareUpdateCalls[prepareUpdateCalls.length - 1]!; + expect(lastPrepare.tag).toBe("div"); + expect(lastPrepare.prevProps.color).toBe("red"); + expect(lastPrepare.nextProps.color).toBe("blue"); + + expect(commitUpdateCalls.length).toBeGreaterThanOrEqual(1); + const lastCommit = commitUpdateCalls[commitUpdateCalls.length - 1]!; + expect(lastCommit.instance).toBe(divFiber.instance); + expect(lastCommit.payload).toEqual({ color: "blue" }); + }); + + it("signal effect disposer stored in fiber.signalDisposers", () => { + const { host } = makeTrackingHost(); + const color = signal("red"); + + const root = createHostRoot(host, {}); + root.render(h("div", { color: color.value })); + + const divFiber = root.rootFiber!.children[0]!; + const beforeCount = divFiber.signalDisposers.length; + + wireSignalToFiber( + divFiber, + () => h("div", { color: color.value }), + host as HostConfig, + {}, + ); + + expect(divFiber.signalDisposers.length).toBe(beforeCount + 1); + expect(typeof divFiber.signalDisposers[divFiber.signalDisposers.length - 1]).toBe("function"); + }); + + it("disposing signal via signalDisposers stops updates", async () => { + const { host, prepareUpdateCalls } = makeTrackingHost(); + const color = signal("red"); + + const root = createHostRoot(host, {}); + root.render(h("div", { color: color.value })); + + const divFiber = root.rootFiber!.children[0]!; + wireSignalToFiber( + divFiber, + () => h("div", { color: color.value }), + host as HostConfig, + {}, + ); + + await new Promise((resolve) => { + queueMicrotask(() => resolve()); + }); + resetUpdateQueue(); + + const disposer = divFiber.signalDisposers.pop()!; + disposer(); + + const callCountBefore = prepareUpdateCalls.filter((c) => c.tag === "div").length; + color.value = "green"; + + await new Promise((resolve) => { + queueMicrotask(() => resolve()); + }); + + expect(prepareUpdateCalls.filter((c) => c.tag === "div").length).toBe(callCountBefore); + }); + }); + + describe("batch of signal changes", () => { + it("batch of signal changes results in single reconciliation pass", async () => { + const { host, prepareUpdateCalls, commitUpdateCalls } = makeTrackingHost(); + const color = signal("red"); + const size = signal("small"); + + const root = createHostRoot(host, {}); + root.render(h("div", { color: color.value, size: size.value })); + + const divFiber = root.rootFiber!.children[0]!; + + wireSignalToFiber( + divFiber, + () => h("div", { color: color.value, size: size.value }), + host as HostConfig, + {}, + ); + + await new Promise((resolve) => { + queueMicrotask(() => resolve()); + }); + resetUpdateQueue(); + prepareUpdateCalls.length = 0; + commitUpdateCalls.length = 0; + + batch(() => { + color.value = "blue"; + size.value = "large"; + }); + + await new Promise((resolve) => { + queueMicrotask(() => resolve()); + }); + + expect(prepareUpdateCalls.filter((c) => c.tag === "div").length).toBe(1); + expect(commitUpdateCalls.filter((c) => c.tag === "div").length).toBe(1); + }); + }); + + describe("render() re-renderable", () => { + it("second render reconciles props against existing fiber tree", () => { + const { host, prepareUpdateCalls, commitUpdateCalls, instances } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" }, "hello")); + + expect(instances.length).toBe(1); + const instanceCountBefore = instances.length; + + root.render(h("div", { color: "blue" }, "hello")); + + expect(instances.length).toBe(instanceCountBefore); + + const divPrepareCalls = prepareUpdateCalls.filter((c) => c.tag === "div"); + expect(divPrepareCalls.length).toBe(1); + expect(divPrepareCalls[0]!.prevProps.color).toBe("red"); + expect(divPrepareCalls[0]!.nextProps.color).toBe("blue"); + + const divCommitCalls = commitUpdateCalls.filter((c) => c.tag === "div"); + expect(divCommitCalls.length).toBe(1); + expect(divCommitCalls[0]!.payload).toEqual({ color: "blue" }); + }); + + it("second render does not create duplicate instances", () => { + const { host, instances } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" })); + + expect(instances.length).toBe(1); + root.render(h("div", { color: "blue" })); + expect(instances.length).toBe(1); + }); + + it("prepareUpdate is called for changed props on re-render", () => { + const { host, prepareUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("span", { label: "a" })); + root.render(h("span", { label: "b" })); + + const spanPrepares = prepareUpdateCalls.filter((c) => c.tag === "span"); + expect(spanPrepares.length).toBe(1); + expect(spanPrepares[0]!.prevProps.label).toBe("a"); + expect(spanPrepares[0]!.nextProps.label).toBe("b"); + }); + }); + + describe("prepareUpdate/commitUpdate optional", () => { + it("host without prepareUpdate is a no-op on re-render", () => { + const host: HostConfig> = { + name: "minimal", + createRootContext: () => ({}), + createInstance: (tag) => tag, + createTextInstance: (text) => text, + appendChild: () => {}, + }; + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" })); + expect(() => root.render(h("div", { color: "blue" }))).not.toThrow(); + }); + }); + + describe("commitEffects top-down order", () => { + it("parent commitUpdate fires before child commitUpdate", () => { + const { host, commitUpdateCalls } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" }, h("span", { label: "a" }))); + + root.render(h("div", { color: "blue" }, h("span", { label: "b" }))); + + const tags = commitUpdateCalls.map((c) => c.tag); + const divIdx = tags.indexOf("div"); + const spanIdx = tags.indexOf("span"); + expect(divIdx).toBeLessThan(spanIdx); + }); + }); + + describe("reconcileProps direct call", () => { + it("updates fiber props and sets effect when prepareUpdate returns payload", () => { + const { host } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" })); + + const divFiber = root.rootFiber!.children[0]!; + expect(divFiber.props.color).toBe("red"); + + reconcileProps(divFiber, h("div", { color: "blue" }), host as HostConfig, {}); + + expect(divFiber.props.color).toBe("blue"); + expect(divFiber.effect).not.toBeNull(); + expect(divFiber.effect!.type).toBe("update"); + expect(divFiber.prevProps!.color).toBe("red"); + }); + + it("no effect when props are unchanged", () => { + const { host } = makeTrackingHost(); + const root = createHostRoot(host, {}); + root.render(h("div", { color: "red" })); + + const divFiber = root.rootFiber!.children[0]!; + reconcileProps(divFiber, h("div", { color: "red" }), host as HostConfig, {}); + + expect(divFiber.effect).toBeNull(); + }); + }); +}); \ No newline at end of file