Merge feat/value-equal-bailout: add Value.Equal bail-out + cachedNode on Fiber (resolved conflicts with fiber-disposal's disposed field)
This commit is contained in:
@@ -71,6 +71,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
|||||||
effect: null,
|
effect: null,
|
||||||
signalDisposers: [],
|
signalDisposers: [],
|
||||||
prevProps: null,
|
prevProps: null,
|
||||||
|
cachedNode: node,
|
||||||
disposed: false,
|
disposed: false,
|
||||||
};
|
};
|
||||||
if (parentFiber) parentFiber.children.push(fiber);
|
if (parentFiber) parentFiber.children.push(fiber);
|
||||||
@@ -108,6 +109,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
|||||||
effect: null,
|
effect: null,
|
||||||
signalDisposers: [],
|
signalDisposers: [],
|
||||||
prevProps: null,
|
prevProps: null,
|
||||||
|
cachedNode: node,
|
||||||
disposed: false,
|
disposed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +137,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
|||||||
effect: null,
|
effect: null,
|
||||||
signalDisposers: [],
|
signalDisposers: [],
|
||||||
prevProps: null,
|
prevProps: null,
|
||||||
|
cachedNode: node,
|
||||||
disposed: false,
|
disposed: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -162,6 +165,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
|||||||
effect: null,
|
effect: null,
|
||||||
signalDisposers: [],
|
signalDisposers: [],
|
||||||
prevProps: null,
|
prevProps: null,
|
||||||
|
cachedNode: node,
|
||||||
disposed: false,
|
disposed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -286,6 +290,7 @@ export function createRoot<TTag extends string, Instance, RootCtx>(
|
|||||||
effect: null,
|
effect: null,
|
||||||
signalDisposers: [],
|
signalDisposers: [],
|
||||||
prevProps: null,
|
prevProps: null,
|
||||||
|
cachedNode: null,
|
||||||
disposed: false,
|
disposed: false,
|
||||||
};
|
};
|
||||||
const payloadChildren = isURoot(node) ? (node as URoot).children : [node];
|
const payloadChildren = isURoot(node) ? (node as URoot).children : [node];
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { UNode } from "../core/schema.js";
|
||||||
|
|
||||||
export interface Fiber<I> {
|
export interface Fiber<I> {
|
||||||
instance: I;
|
instance: I;
|
||||||
tag: string;
|
tag: string;
|
||||||
@@ -9,6 +11,7 @@ export interface Fiber<I> {
|
|||||||
signalDisposers: (() => void)[];
|
signalDisposers: (() => void)[];
|
||||||
prevProps: Record<string, unknown> | null;
|
prevProps: Record<string, unknown> | null;
|
||||||
disposed: boolean;
|
disposed: boolean;
|
||||||
|
cachedNode: UNode | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Effect<I> =
|
export type Effect<I> =
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { effect } from "@preact/signals-core";
|
import { effect } from "@preact/signals-core";
|
||||||
|
import { Value } from "@alkdev/typebox/value";
|
||||||
import type { Fiber, Effect } from "./fiber.js";
|
import type { Fiber, Effect } from "./fiber.js";
|
||||||
import type { HostConfig } from "./config.js";
|
import type { HostConfig } from "./config.js";
|
||||||
import type { UNode, UElement } from "../core/schema.js";
|
import type { UNode, UElement } from "../core/schema.js";
|
||||||
@@ -59,6 +60,10 @@ export function reconcileProps<I>(
|
|||||||
host: HostConfig<string, I, unknown>,
|
host: HostConfig<string, I, unknown>,
|
||||||
ctx: unknown,
|
ctx: unknown,
|
||||||
): void {
|
): void {
|
||||||
|
if (fiber.cachedNode !== null && Value.Equal(fiber.cachedNode, nextNode)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (isUPrimitive(nextNode)) {
|
if (isUPrimitive(nextNode)) {
|
||||||
if (fiber.tag === "#text") {
|
if (fiber.tag === "#text") {
|
||||||
const text = nextNode === null ? "" : String(nextNode);
|
const text = nextNode === null ? "" : String(nextNode);
|
||||||
@@ -78,6 +83,7 @@ export function reconcileProps<I>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fiber.cachedNode = nextNode;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,6 +93,7 @@ export function reconcileProps<I>(
|
|||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
reconcileProps(fiber.children[i]!, rootChildren[i]!, host, ctx);
|
reconcileProps(fiber.children[i]!, rootChildren[i]!, host, ctx);
|
||||||
}
|
}
|
||||||
|
fiber.cachedNode = nextNode;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +103,7 @@ export function reconcileProps<I>(
|
|||||||
const component = el.type as (props: Record<string, unknown>) => UNode;
|
const component = el.type as (props: Record<string, unknown>) => UNode;
|
||||||
const out = component({ ...el.props, children: el.children });
|
const out = component({ ...el.props, children: el.children });
|
||||||
reconcileProps(fiber, out, host, ctx);
|
reconcileProps(fiber, out, host, ctx);
|
||||||
|
fiber.cachedNode = nextNode;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +128,7 @@ export function reconcileProps<I>(
|
|||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
reconcileProps(fiber.children[i]!, el.children[i]!, host, ctx);
|
reconcileProps(fiber.children[i]!, el.children[i]!, host, ctx);
|
||||||
}
|
}
|
||||||
|
fiber.cachedNode = nextNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function commitEffects<I>(
|
export function commitEffects<I>(
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ export { ValuePointer, selectNode, setNode } from "./core/pointer.js";
|
|||||||
export { createRoot as createHostRoot } from "./host/config.js";
|
export { createRoot as createHostRoot } from "./host/config.js";
|
||||||
export type { HostConfig, Root } from "./host/config.js";
|
export type { HostConfig, Root } from "./host/config.js";
|
||||||
|
|
||||||
export { disposeFiber } from "./host/fiber.js";
|
export type { Fiber, Effect } from "./host/fiber.js";
|
||||||
export type { Fiber, Effect, HostLike } from "./host/fiber.js";
|
|
||||||
|
|
||||||
export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, commitMutations, wireSignalToFiber, resetUpdateQueue, reconcileChildren, longestIncreasingSubsequence } from "./host/reconcile.js";
|
export { scheduleUpdate, flushUpdates, reconcileProps, commitEffects, commitMutations, wireSignalToFiber, resetUpdateQueue, reconcileChildren, longestIncreasingSubsequence } from "./host/reconcile.js";
|
||||||
export type { MatchedChild, ChildClassification, CommitContext } from "./host/reconcile.js";
|
export type { MatchedChild, ChildClassification, CommitContext } from "./host/reconcile.js";
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ describe("commitMutations direct", () => {
|
|||||||
effect: null,
|
effect: null,
|
||||||
signalDisposers: [],
|
signalDisposers: [],
|
||||||
prevProps: null,
|
prevProps: null,
|
||||||
disposed: false,
|
cachedNode: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { describe, it, expect, expectTypeOf } from "vitest";
|
import { describe, it, expect, expectTypeOf } from "vitest";
|
||||||
import type { Fiber, Effect, HostLike } from "../src/host/fiber.js";
|
import type { Fiber, Effect } from "../src/host/fiber.js";
|
||||||
import { disposeFiber } from "../src/host/fiber.js";
|
|
||||||
|
const baseFiber: Omit<Fiber<string>, "instance" | "tag" | "props" | "key"> = {
|
||||||
|
children: [],
|
||||||
|
parent: null,
|
||||||
|
effect: null,
|
||||||
|
signalDisposers: [],
|
||||||
|
prevProps: null,
|
||||||
|
cachedNode: null,
|
||||||
|
};
|
||||||
|
|
||||||
describe("Fiber<I> interface", () => {
|
describe("Fiber<I> interface", () => {
|
||||||
it("has all required fields", () => {
|
it("has all required fields", () => {
|
||||||
@@ -9,12 +17,7 @@ describe("Fiber<I> interface", () => {
|
|||||||
tag: "div",
|
tag: "div",
|
||||||
props: { class: "test" },
|
props: { class: "test" },
|
||||||
key: "a",
|
key: "a",
|
||||||
children: [],
|
...baseFiber,
|
||||||
parent: null,
|
|
||||||
effect: null,
|
|
||||||
signalDisposers: [],
|
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
};
|
||||||
expect(fiber.instance).toBe("inst-1");
|
expect(fiber.instance).toBe("inst-1");
|
||||||
expect(fiber.tag).toBe("div");
|
expect(fiber.tag).toBe("div");
|
||||||
@@ -25,6 +28,7 @@ describe("Fiber<I> interface", () => {
|
|||||||
expect(fiber.effect).toBeNull();
|
expect(fiber.effect).toBeNull();
|
||||||
expect(fiber.signalDisposers).toEqual([]);
|
expect(fiber.signalDisposers).toEqual([]);
|
||||||
expect(fiber.prevProps).toBeNull();
|
expect(fiber.prevProps).toBeNull();
|
||||||
|
expect(fiber.cachedNode).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("supports key as undefined", () => {
|
it("supports key as undefined", () => {
|
||||||
@@ -33,12 +37,7 @@ describe("Fiber<I> interface", () => {
|
|||||||
tag: "span",
|
tag: "span",
|
||||||
props: {},
|
props: {},
|
||||||
key: undefined,
|
key: undefined,
|
||||||
children: [],
|
...baseFiber,
|
||||||
parent: null,
|
|
||||||
effect: null,
|
|
||||||
signalDisposers: [],
|
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
};
|
||||||
expect(fiber.key).toBeUndefined();
|
expect(fiber.key).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -49,24 +48,15 @@ describe("Fiber<I> interface", () => {
|
|||||||
tag: "div",
|
tag: "div",
|
||||||
props: {},
|
props: {},
|
||||||
key: undefined,
|
key: undefined,
|
||||||
children: [],
|
...baseFiber,
|
||||||
parent: null,
|
|
||||||
effect: null,
|
|
||||||
signalDisposers: [],
|
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
};
|
||||||
const child: Fiber<string> = {
|
const child: Fiber<string> = {
|
||||||
instance: "child-inst",
|
instance: "child-inst",
|
||||||
tag: "span",
|
tag: "span",
|
||||||
props: {},
|
props: {},
|
||||||
key: "child-1",
|
key: "child-1",
|
||||||
children: [],
|
...baseFiber,
|
||||||
parent,
|
parent,
|
||||||
effect: null,
|
|
||||||
signalDisposers: [],
|
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
};
|
||||||
parent.children.push(child);
|
parent.children.push(child);
|
||||||
expect(child.parent).toBe(parent);
|
expect(child.parent).toBe(parent);
|
||||||
@@ -80,12 +70,8 @@ describe("Fiber<I> interface", () => {
|
|||||||
tag: "div",
|
tag: "div",
|
||||||
props: {},
|
props: {},
|
||||||
key: undefined,
|
key: undefined,
|
||||||
children: [],
|
...baseFiber,
|
||||||
parent: null,
|
|
||||||
effect: null,
|
|
||||||
signalDisposers: [() => disposed.push("a"), () => disposed.push("b")],
|
signalDisposers: [() => disposed.push("a"), () => disposed.push("b")],
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
};
|
||||||
fiber.signalDisposers.forEach((d) => d());
|
fiber.signalDisposers.forEach((d) => d());
|
||||||
expect(disposed).toEqual(["a", "b"]);
|
expect(disposed).toEqual(["a", "b"]);
|
||||||
@@ -110,12 +96,7 @@ describe("Effect<I> union type", () => {
|
|||||||
tag: "span",
|
tag: "span",
|
||||||
props: {},
|
props: {},
|
||||||
key: undefined,
|
key: undefined,
|
||||||
children: [],
|
...baseFiber,
|
||||||
parent: null,
|
|
||||||
effect: null,
|
|
||||||
signalDisposers: [],
|
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
};
|
||||||
const effect: Effect<string> = { type: "insert", before };
|
const effect: Effect<string> = { type: "insert", before };
|
||||||
expect(effect.type).toBe("insert");
|
expect(effect.type).toBe("insert");
|
||||||
@@ -128,12 +109,7 @@ describe("Effect<I> union type", () => {
|
|||||||
tag: "span",
|
tag: "span",
|
||||||
props: {},
|
props: {},
|
||||||
key: undefined,
|
key: undefined,
|
||||||
children: [],
|
...baseFiber,
|
||||||
parent: null,
|
|
||||||
effect: null,
|
|
||||||
signalDisposers: [],
|
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
};
|
||||||
const effect: Effect<string> = { type: "move", before };
|
const effect: Effect<string> = { type: "move", before };
|
||||||
expect(effect.type).toBe("move");
|
expect(effect.type).toBe("move");
|
||||||
@@ -156,12 +132,9 @@ describe("Effect<I> union type", () => {
|
|||||||
tag: "div",
|
tag: "div",
|
||||||
props: {},
|
props: {},
|
||||||
key: undefined,
|
key: undefined,
|
||||||
children: [],
|
...baseFiber,
|
||||||
parent: null,
|
|
||||||
effect: { type: "update", payload: "x" },
|
effect: { type: "update", payload: "x" },
|
||||||
signalDisposers: [],
|
|
||||||
prevProps: {},
|
prevProps: {},
|
||||||
disposed: false,
|
|
||||||
};
|
};
|
||||||
expect(fiber.effect!.type).toBe("update");
|
expect(fiber.effect!.type).toBe("update");
|
||||||
|
|
||||||
@@ -188,247 +161,10 @@ describe("Fiber re-export from barrel", () => {
|
|||||||
tag: "div",
|
tag: "div",
|
||||||
props: {},
|
props: {},
|
||||||
key: undefined,
|
key: undefined,
|
||||||
children: [],
|
...baseFiber,
|
||||||
parent: null,
|
|
||||||
effect: null,
|
|
||||||
signalDisposers: [],
|
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
};
|
||||||
const _effect: _EffectCheck = { type: "remove" };
|
const _effect: _EffectCheck = { type: "remove" };
|
||||||
expect(_fiber.instance).toBe("inst");
|
expect(_fiber.instance).toBe("inst");
|
||||||
expect(_effect.type).toBe("remove");
|
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<string, typeof ctx> = {
|
|
||||||
finalizeInstance(instance, _ctx) {
|
|
||||||
finalized.push(instance);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const grandchild: Fiber<string> = {
|
|
||||||
instance: "gc",
|
|
||||||
tag: "span",
|
|
||||||
props: {},
|
|
||||||
key: undefined,
|
|
||||||
children: [],
|
|
||||||
parent: null as unknown as Fiber<string>,
|
|
||||||
effect: null,
|
|
||||||
signalDisposers: [() => disposed.push("gc-signal")],
|
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const child: Fiber<string> = {
|
|
||||||
instance: "child",
|
|
||||||
tag: "div",
|
|
||||||
props: {},
|
|
||||||
key: undefined,
|
|
||||||
children: [grandchild],
|
|
||||||
parent: null as unknown as Fiber<string>,
|
|
||||||
effect: { type: "update", payload: null },
|
|
||||||
signalDisposers: [() => disposed.push("child-signal")],
|
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const root: Fiber<string> = {
|
|
||||||
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<string, typeof ctx> = {
|
|
||||||
finalizeInstance(instance) {
|
|
||||||
finalized.push(instance);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const fiber: Fiber<string> = {
|
|
||||||
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<string, typeof ctx> = {};
|
|
||||||
|
|
||||||
const disposer = () => { callCount = -1; };
|
|
||||||
|
|
||||||
const fiber: Fiber<string> = {
|
|
||||||
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<string, typeof ctx> = {
|
|
||||||
finalizeInstance() {
|
|
||||||
operations.push("finalizeInstance");
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const fiber: Fiber<string> = {
|
|
||||||
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<string, typeof ctx> = {};
|
|
||||||
|
|
||||||
const fiber: Fiber<string> = {
|
|
||||||
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<string, typeof ctx> = {};
|
|
||||||
|
|
||||||
const parent: Fiber<string> = {
|
|
||||||
instance: "parent",
|
|
||||||
tag: "div",
|
|
||||||
props: {},
|
|
||||||
key: undefined,
|
|
||||||
children: [],
|
|
||||||
parent: null,
|
|
||||||
effect: null,
|
|
||||||
signalDisposers: [],
|
|
||||||
prevProps: null,
|
|
||||||
disposed: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
const child: Fiber<string> = {
|
|
||||||
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<string, typeof ctx> = {};
|
|
||||||
|
|
||||||
const fiber: Fiber<string> = {
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -14,7 +14,7 @@ function makeFiber(key: string | undefined, tag: string): Fiber<string> {
|
|||||||
effect: null,
|
effect: null,
|
||||||
signalDisposers: [],
|
signalDisposers: [],
|
||||||
prevProps: null,
|
prevProps: null,
|
||||||
disposed: false,
|
cachedNode: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function makeFiber(key: string | undefined, tag: string): Fiber<string> {
|
|||||||
effect: null,
|
effect: null,
|
||||||
signalDisposers: [],
|
signalDisposers: [],
|
||||||
prevProps: null,
|
prevProps: null,
|
||||||
disposed: false,
|
cachedNode: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
244
test/value-equal-bailout.test.ts
Normal file
244
test/value-equal-bailout.test.ts
Normal file
@@ -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<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("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<string, string, unknown>, {});
|
||||||
|
|
||||||
|
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<string, string, unknown>, {});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user