Merge feat/signal-driven-updates with conflict resolution (reconcile.ts extracted, render re-renderable preserved)
This commit is contained in:
@@ -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<TTag extends string, Instance, RootCtx> {
|
||||
name: string;
|
||||
@@ -40,11 +41,6 @@ export interface Root<TTag extends string, Instance, RootCtx> {
|
||||
unmount(): void;
|
||||
}
|
||||
|
||||
function resolveChildren(node: UNode): UNode[] {
|
||||
if (isURoot(node)) return (node as URoot).children;
|
||||
return [node];
|
||||
}
|
||||
|
||||
export function createRoot<TTag extends string, Instance, RootCtx>(
|
||||
host: HostConfig<TTag, Instance, RootCtx>,
|
||||
container: unknown,
|
||||
@@ -102,7 +98,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
||||
const fiber: Fiber<Instance> = {
|
||||
instance: inst,
|
||||
tag,
|
||||
props: { ...el.props } as Record<string, unknown>,
|
||||
props: el.props as Record<string, unknown>,
|
||||
key: el.key,
|
||||
children: [],
|
||||
parent: parentFiber,
|
||||
@@ -120,95 +116,6 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
||||
return fiber;
|
||||
}
|
||||
|
||||
function reconcileNode(fiber: Fiber<Instance>, 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<string, unknown>;
|
||||
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<Instance>): 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<TTag extends string, Instance, RootCtx>(
|
||||
context: rootContext,
|
||||
rootFiber: null,
|
||||
render(node: UNode) {
|
||||
const payloadChildren = resolveChildren(node);
|
||||
|
||||
if (this.rootFiber === null) {
|
||||
const root: Fiber<Instance> = {
|
||||
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<string, Instance, unknown>, 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<string, Instance, unknown>, ctx as unknown);
|
||||
host.emit?.("root.render", `root_${Date.now()}`, { childCount: payloadChildren.length });
|
||||
return;
|
||||
}
|
||||
|
||||
const root: Fiber<Instance> = {
|
||||
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 });
|
||||
},
|
||||
|
||||
157
src/host/reconcile.ts
Normal file
157
src/host/reconcile.ts
Normal file
@@ -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<unknown>;
|
||||
nextNode: UNode;
|
||||
}
|
||||
|
||||
let pendingUpdates: PendingUpdate[] = [];
|
||||
let flushScheduled = false;
|
||||
|
||||
export function scheduleUpdate<I>(
|
||||
fiber: Fiber<I>,
|
||||
nextNode: UNode,
|
||||
host: HostConfig<string, I, unknown>,
|
||||
ctx: unknown,
|
||||
): void {
|
||||
pendingUpdates.push({ fiber: fiber as Fiber<unknown>, nextNode });
|
||||
if (!flushScheduled) {
|
||||
flushScheduled = true;
|
||||
queueMicrotask(() => flushUpdates(host as HostConfig<string, unknown, unknown>, ctx));
|
||||
}
|
||||
}
|
||||
|
||||
export function flushUpdates(
|
||||
host: HostConfig<string, unknown, unknown>,
|
||||
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<Fiber<unknown>>();
|
||||
for (const { fiber } of updates) {
|
||||
let root: Fiber<unknown> | null = fiber;
|
||||
while (root.parent) root = root.parent;
|
||||
if (!seen.has(root)) {
|
||||
seen.add(root);
|
||||
commitEffects(root, host, ctx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function reconcileProps<I>(
|
||||
fiber: Fiber<I>,
|
||||
nextNode: UNode,
|
||||
host: HostConfig<string, I, unknown>,
|
||||
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<string, unknown>) => 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<string, unknown>;
|
||||
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<I>(
|
||||
fiber: Fiber<I>,
|
||||
host: HostConfig<string, I, unknown>,
|
||||
ctx: unknown,
|
||||
): void {
|
||||
if (fiber.effect?.type === "update") {
|
||||
const updateEffect = fiber.effect as Effect<I> & { 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<I>(
|
||||
fiber: Fiber<I>,
|
||||
signalGetter: () => UNode,
|
||||
host: HostConfig<string, I, unknown>,
|
||||
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;
|
||||
}
|
||||
@@ -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";
|
||||
299
test/signal-driven-updates.test.ts
Normal file
299
test/signal-driven-updates.test.ts
Normal file
@@ -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<string, unknown>; nextProps: Record<string, unknown> }[] = [];
|
||||
const commitUpdateCalls: { instance: string; payload: unknown; tag: string; prevProps: Record<string, unknown>; nextProps: Record<string, unknown> }[] = [];
|
||||
const instances: { tag: string; props: Record<string, unknown> }[] = [];
|
||||
const texts: string[] = [];
|
||||
const appends: { parent: string; child: string }[] = [];
|
||||
|
||||
const host: HostConfig<string, string, Record<string, unknown>> = {
|
||||
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<string, unknown> = {};
|
||||
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<string, string, unknown>,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(prepareUpdateCalls.length).toBe(0);
|
||||
expect(commitUpdateCalls.length).toBe(0);
|
||||
|
||||
color.value = "blue";
|
||||
|
||||
await new Promise<void>((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<string, string, unknown>,
|
||||
{},
|
||||
);
|
||||
|
||||
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<string, string, unknown>,
|
||||
{},
|
||||
);
|
||||
|
||||
await new Promise<void>((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<void>((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<string, string, unknown>,
|
||||
{},
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
queueMicrotask(() => resolve());
|
||||
});
|
||||
resetUpdateQueue();
|
||||
prepareUpdateCalls.length = 0;
|
||||
commitUpdateCalls.length = 0;
|
||||
|
||||
batch(() => {
|
||||
color.value = "blue";
|
||||
size.value = "large";
|
||||
});
|
||||
|
||||
await new Promise<void>((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<string, string, Record<string, unknown>> = {
|
||||
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<string, string, unknown>, {});
|
||||
|
||||
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<string, string, unknown>, {});
|
||||
|
||||
expect(divFiber.effect).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user