diff --git a/src/host/config.ts b/src/host/config.ts index 43dd2bc..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; @@ -40,11 +41,6 @@ export interface Root { unmount(): void; } -function resolveChildren(node: UNode): UNode[] { - if (isURoot(node)) return (node as URoot).children; - return [node]; -} - export function createRoot( host: HostConfig, container: unknown, @@ -102,7 +98,7 @@ export function createRoot( const fiber: Fiber = { instance: inst, tag, - props: { ...el.props } as Record, + props: el.props as Record, key: el.key, children: [], parent: parentFiber, @@ -120,95 +116,6 @@ export function createRoot( return fiber; } - function reconcileNode(fiber: Fiber, node: UNode): void { - if (node == null || node === false) return; - - if (isUPrimitive(node)) { - const nextText = node === null ? "" : String(node); - if (fiber.tag === "#text") { - const prevProps = { ...fiber.props }; - const nextProps = { text: nextText }; - const payload = host.prepareUpdate?.(fiber.instance, fiber.tag as TTag, prevProps, nextProps, ctx); - if (payload !== null && payload !== undefined) { - fiber.prevProps = prevProps; - fiber.props = nextProps; - fiber.effect = { type: "update", payload }; - } - } - return; - } - - if (isURoot(node)) { - const newChildren = (node as URoot).children; - const count = Math.min(fiber.children.length, newChildren.length); - for (let i = 0; i < count; i++) { - reconcileNode(fiber.children[i]!, newChildren[i]!); - } - return; - } - - const el = node as UElement; - - if (typeof el.type === "function") { - const component = el.type as ComponentFn; - const out = component({ ...el.props, children: el.children }); - reconcileNode(fiber, out); - return; - } - - if (fiber.tag !== el.type) return; - - const tag = el.type as TTag; - const prevProps = { ...fiber.props }; - const nextProps = { ...el.props } as Record; - const payload = host.prepareUpdate?.(fiber.instance, tag, prevProps, nextProps, ctx); - - if (payload !== null && payload !== undefined) { - fiber.prevProps = prevProps; - fiber.props = nextProps; - fiber.effect = { type: "update", payload }; - } - - if (el.children.length > 0 || fiber.children.length > 0) { - const elChildren = flattenChildren(el.children); - const count = Math.min(fiber.children.length, elChildren.length); - for (let i = 0; i < count; i++) { - reconcileNode(fiber.children[i]!, elChildren[i]!); - } - } - } - - function flattenChildren(nodes: UNode[]): UNode[] { - const result: UNode[] = []; - for (const n of nodes) { - if (n == null || n === false) continue; - if (Array.isArray(n)) { - result.push(...flattenChildren(n as UNode[])); - } else { - result.push(n); - } - } - return result; - } - - function commitEffects(fiber: Fiber): void { - if (fiber.effect?.type === "update") { - host.commitUpdate?.( - fiber.instance, - fiber.effect.payload, - fiber.tag as TTag, - fiber.prevProps!, - fiber.props, - ctx, - ); - } - fiber.effect = null; - fiber.prevProps = null; - for (const child of fiber.children) { - commitEffects(child); - } - } - return { host, ctx, @@ -216,33 +123,35 @@ export function createRoot( context: rootContext, rootFiber: null, render(node: UNode) { - const payloadChildren = resolveChildren(node); - - if (this.rootFiber === null) { - const root: Fiber = { - instance: undefined as unknown as Instance, - tag: "#root", - props: {}, - key: undefined, - children: [], - parent: null, - effect: null, - signalDisposers: [], - prevProps: null, - }; - for (const child of payloadChildren) { - mountNode(child, root); + 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); + } } - this.rootFiber = root; - } else { - const rootFiber = this.rootFiber; - const count = Math.min(rootFiber.children.length, payloadChildren.length); - for (let i = 0; i < count; i++) { - reconcileNode(rootFiber.children[i]!, payloadChildren[i]!); - } - commitEffects(rootFiber); + 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", + props: {}, + key: undefined, + children: [], + parent: null, + effect: null, + signalDisposers: [], + prevProps: null, + }; + const payloadChildren = isURoot(node) ? (node as URoot).children : [node]; + for (const child of payloadChildren) { + mountNode(child, root); + } + this.rootFiber = root; host.finalizeRoot?.(ctx); host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length }); }, 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