diff --git a/src/host/config.ts b/src/host/config.ts index 7f8c339..05b35e0 100644 --- a/src/host/config.ts +++ b/src/host/config.ts @@ -30,6 +30,7 @@ export interface HostConfig { ctx: RootCtx, ): void; emit?(type: string, id: string, payload: unknown): void; + finalizeInstance?(instance: Instance, ctx: RootCtx): void; } export interface Root { @@ -70,6 +71,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; if (parentFiber) parentFiber.children.push(fiber); return fiber; @@ -106,6 +108,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; for (const child of el.children) { @@ -132,6 +135,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; } @@ -158,6 +162,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; for (const child of el.children) { @@ -281,6 +286,7 @@ export function createRoot( effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; 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..dbf7eb1 100644 --- a/src/host/fiber.ts +++ b/src/host/fiber.ts @@ -8,10 +8,34 @@ export interface Fiber { effect: Effect | null; signalDisposers: (() => void)[]; prevProps: Record | null; + disposed: boolean; } export type Effect = | { type: "update"; payload: unknown } | { type: "insert"; before: Fiber | null } | { type: "move"; before: Fiber | null } - | { type: "remove" }; \ No newline at end of file + | { type: "remove" }; + +export interface HostLike { + finalizeInstance?(instance: I, ctx: Ctx): void; +} + +export function disposeFiber(fiber: Fiber, host: HostLike, ctx: Ctx): void { + if (fiber.disposed) return; + + for (const child of fiber.children) { + disposeFiber(child, host, ctx); + } + + host.finalizeInstance?.(fiber.instance, ctx); + + for (const disposer of fiber.signalDisposers) { + disposer(); + } + fiber.signalDisposers = []; + + fiber.parent = null; + fiber.effect = null; + fiber.disposed = true; +} \ No newline at end of file diff --git a/src/mod.ts b/src/mod.ts index 6f1550d..d7c06f0 100644 --- a/src/mod.ts +++ b/src/mod.ts @@ -19,7 +19,8 @@ export { ValuePointer, selectNode, setNode } from "./core/pointer.js"; export { createRoot as createHostRoot } from "./host/config.js"; export type { HostConfig, Root } from "./host/config.js"; -export type { Fiber, Effect } from "./host/fiber.js"; +export { disposeFiber } 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 type { MatchedChild, ChildClassification, CommitContext } from "./host/reconcile.js"; diff --git a/test/commit-mutations.test.ts b/test/commit-mutations.test.ts index a4d6b0f..97efdcb 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, + disposed: false, }; } diff --git a/test/fiber.test.ts b/test/fiber.test.ts index a2ca9e6..baf5f68 100644 --- a/test/fiber.test.ts +++ b/test/fiber.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, expectTypeOf } from "vitest"; -import type { Fiber, Effect } from "../src/host/fiber.js"; +import type { Fiber, Effect, HostLike } from "../src/host/fiber.js"; +import { disposeFiber } from "../src/host/fiber.js"; describe("Fiber interface", () => { it("has all required fields", () => { @@ -13,6 +14,7 @@ describe("Fiber interface", () => { effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; expect(fiber.instance).toBe("inst-1"); expect(fiber.tag).toBe("div"); @@ -36,6 +38,7 @@ describe("Fiber interface", () => { effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; expect(fiber.key).toBeUndefined(); }); @@ -51,6 +54,7 @@ describe("Fiber interface", () => { effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; const child: Fiber = { instance: "child-inst", @@ -62,6 +66,7 @@ describe("Fiber interface", () => { effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; parent.children.push(child); expect(child.parent).toBe(parent); @@ -80,6 +85,7 @@ describe("Fiber interface", () => { effect: null, signalDisposers: [() => disposed.push("a"), () => disposed.push("b")], prevProps: null, + disposed: false, }; fiber.signalDisposers.forEach((d) => d()); expect(disposed).toEqual(["a", "b"]); @@ -109,6 +115,7 @@ describe("Effect union type", () => { effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; const effect: Effect = { type: "insert", before }; expect(effect.type).toBe("insert"); @@ -126,6 +133,7 @@ describe("Effect union type", () => { effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; const effect: Effect = { type: "move", before }; expect(effect.type).toBe("move"); @@ -153,6 +161,7 @@ describe("Effect union type", () => { effect: { type: "update", payload: "x" }, signalDisposers: [], prevProps: {}, + disposed: false, }; expect(fiber.effect!.type).toBe("update"); @@ -184,9 +193,242 @@ describe("Fiber re-export from barrel", () => { effect: null, signalDisposers: [], prevProps: null, + disposed: false, }; const _effect: _EffectCheck = { type: "remove" }; expect(_fiber.instance).toBe("inst"); 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 = { + finalizeInstance(instance, _ctx) { + finalized.push(instance); + }, + }; + + const grandchild: Fiber = { + instance: "gc", + tag: "span", + props: {}, + key: undefined, + children: [], + parent: null as unknown as Fiber, + effect: null, + signalDisposers: [() => disposed.push("gc-signal")], + prevProps: null, + disposed: false, + }; + + const child: Fiber = { + instance: "child", + tag: "div", + props: {}, + key: undefined, + children: [grandchild], + parent: null as unknown as Fiber, + effect: { type: "update", payload: null }, + signalDisposers: [() => disposed.push("child-signal")], + prevProps: null, + disposed: false, + }; + + const root: Fiber = { + 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 = { + finalizeInstance(instance) { + finalized.push(instance); + }, + }; + + const fiber: Fiber = { + 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 = {}; + + const disposer = () => { callCount = -1; }; + + const fiber: Fiber = { + 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 = { + finalizeInstance() { + operations.push("finalizeInstance"); + }, + }; + + const fiber: Fiber = { + 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 = {}; + + const fiber: Fiber = { + 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 = {}; + + const parent: Fiber = { + instance: "parent", + tag: "div", + props: {}, + key: undefined, + children: [], + parent: null, + effect: null, + signalDisposers: [], + prevProps: null, + disposed: false, + }; + + const child: Fiber = { + 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 = {}; + + const fiber: Fiber = { + 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(); + }); }); \ No newline at end of file diff --git a/test/key-matching-algorithm.test.ts b/test/key-matching-algorithm.test.ts index b5c110c..1265702 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, + disposed: false, }; } diff --git a/test/lis-move-detection.test.ts b/test/lis-move-detection.test.ts index b343201..4c72917 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, + disposed: false, }; }