diff --git a/src/fiber/effectList.ts b/src/fiber/effectList.ts new file mode 100644 index 0000000..13b5193 --- /dev/null +++ b/src/fiber/effectList.ts @@ -0,0 +1,546 @@ +/* **************** */ +/* Effect List Collection */ +/* **************** */ + +/** + * Implements effect list management for the fiber architecture. + * Effects are stored as a circular linked list on the fiber's updateQueue. + */ + +import type { EffectCreate, Fiber } from "./types"; +import { + type Effect, + HookEffectTag, + Passive, + WorkTag, + createFlags, +} from "./types"; + +// ============================================ +// Effect Types +// ============================================ + +/** + * Update queue structure for effects. + * Effects are stored in a circular linked list. + */ +export type FunctionComponentUpdateQueue = { + lastEffect: Effect | null; +}; + +// ============================================ +// Effect Creation +// ============================================ + +/** + * Creates an effect object. + * Does not attach it to any fiber — use pushEffectToFiber for that. + */ +export function createEffect( + tag: number, + create: EffectCreate, + destroy: (() => void) | undefined, + deps: readonly unknown[] | null, +): Effect { + const effect: Effect = { + tag, + create, + destroy, + deps, + next: null, + }; + + return effect; +} + +/** + * Adds an effect to a fiber's update queue. + * Maintains a circular linked list of effects. + */ +export function createEffectToFiber(fiber: Fiber, effect: Effect): void { + let componentUpdateQueue = + fiber.updateQueue as FunctionComponentUpdateQueue | null; + + if (componentUpdateQueue === null) { + componentUpdateQueue = createFunctionComponentUpdateQueue(); + fiber.updateQueue = componentUpdateQueue as unknown as Fiber["updateQueue"]; + componentUpdateQueue.lastEffect = effect.next = effect; + } else { + const lastEffect = componentUpdateQueue.lastEffect; + if (lastEffect === null) { + componentUpdateQueue.lastEffect = effect.next = effect; + } else { + const firstEffect = lastEffect.next; + lastEffect.next = effect; + effect.next = firstEffect; + componentUpdateQueue.lastEffect = effect; + } + } +} + +/** + * Creates an empty function component update queue. + */ +function createFunctionComponentUpdateQueue(): FunctionComponentUpdateQueue { + return { + lastEffect: null, + }; +} + +// ============================================ +// Effect Collection +// ============================================ + +/** + * Result of collecting effects from a finished work tree. + */ +export type CollectedEffects = { + passiveEffects: EffectInstance[]; + layoutEffects: EffectInstance[]; + /** Cleanup-only effects from unmounted components (run destroy but not create) */ + passiveCleanups: EffectInstance[]; + layoutCleanups: EffectInstance[]; +}; + +/** + * An effect instance with its associated fiber. + */ +export type EffectInstance = { + fiber: Fiber; + effect: Effect; +}; + +/** + * Collects all effects from the finished work tree. + * Separates effects into passive (useEffect) and layout (useLayoutEffect). + */ +export function collectEffects(finishedWork: Fiber): CollectedEffects { + const passiveEffects: EffectInstance[] = []; + const layoutEffects: EffectInstance[] = []; + + // Traverse the fiber tree and collect effects + collectEffectsFromFiber(finishedWork, passiveEffects, layoutEffects); + + return { + passiveEffects, + layoutEffects, + passiveCleanups: [], + layoutCleanups: [], + }; +} + +/** + * Collects effects from both old and new trees for proper cleanup. + * Effects from old tree need cleanup (if fiber no longer has effects), + * effects from new tree need to run. + */ +export function collectEffectsWithCleanup( + finishedWork: Fiber, + previousCurrent: Fiber, +): CollectedEffects { + // Collect effects from new tree (for create functions) + // These already have destroy copied from old effects when deps change + const newPassiveEffects: EffectInstance[] = []; + const newLayoutEffects: EffectInstance[] = []; + collectEffectsFromFiber(finishedWork, newPassiveEffects, newLayoutEffects); + + // Collect cleanup effects from old fibers that no longer have effects + // (component returned early without calling hooks, or component deleted) + const cleanupPassiveEffects: EffectInstance[] = []; + const cleanupLayoutEffects: EffectInstance[] = []; + collectOrphanedEffects( + previousCurrent, + finishedWork, + cleanupPassiveEffects, + cleanupLayoutEffects, + ); + + return { + passiveEffects: newPassiveEffects, + layoutEffects: newLayoutEffects, + passiveCleanups: cleanupPassiveEffects, + layoutCleanups: cleanupLayoutEffects, + }; +} + +/** + * Collects effects from old tree fibers that don't have matching effects + * in the new tree (orphaned effects that need cleanup). + */ +function collectOrphanedEffects( + oldFiber: Fiber, + newFiber: Fiber | null, + passiveEffects: EffectInstance[], + layoutEffects: EffectInstance[], +): void { + // Check if this fiber is a function component with effects + if (oldFiber.tag === WorkTag.FunctionComponent) { + const oldUpdateQueue = + oldFiber.updateQueue as FunctionComponentUpdateQueue | null; + + // Check if new fiber has effects + const newUpdateQueue = + newFiber?.tag === WorkTag.FunctionComponent + ? (newFiber.updateQueue as FunctionComponentUpdateQueue | null) + : null; + const newHasEffects = + newUpdateQueue !== null && newUpdateQueue.lastEffect !== null; + + // If old has effects but new doesn't, collect for cleanup + if ( + oldUpdateQueue !== null && + oldUpdateQueue.lastEffect !== null && + !newHasEffects + ) { + const lastEffect = oldUpdateQueue.lastEffect; + const firstEffect = lastEffect.next; + + if (firstEffect !== null) { + let effect: Effect | null = firstEffect; + + do { + if (effect.destroy !== undefined) { + const instance: EffectInstance = { fiber: oldFiber, effect }; + + if ((effect.tag & HookEffectTag.Passive) !== 0) { + passiveEffects.push(instance); + } else if ((effect.tag & HookEffectTag.Layout) !== 0) { + layoutEffects.push(instance); + } + } + + effect = effect.next; + } while (effect !== null && effect !== firstEffect); + } + } + } + + // Recurse into children, matching old and new child fibers + let oldChild = oldFiber.child; + let newChild = newFiber?.child ?? null; + + while (oldChild !== null) { + // Try to find matching new child (by alternate relationship) + let matchingNewChild: Fiber | null = null; + if (newChild !== null) { + // Check if this is the matching fiber (via alternate) + if (newChild.alternate === oldChild || oldChild.alternate === newChild) { + matchingNewChild = newChild; + newChild = newChild.sibling; + } else { + // Try to find a match in remaining siblings + let searchNew: Fiber | null = newChild; + while (searchNew !== null) { + if ( + searchNew.alternate === oldChild || + oldChild.alternate === searchNew + ) { + matchingNewChild = searchNew; + break; + } + searchNew = searchNew.sibling; + } + } + } + + collectOrphanedEffects( + oldChild, + matchingNewChild, + passiveEffects, + layoutEffects, + ); + oldChild = oldChild.sibling; + } +} + +/** + * Recursively collects effects from a fiber and its descendants. + */ +function collectEffectsFromFiber( + fiber: Fiber, + passiveEffects: EffectInstance[], + layoutEffects: EffectInstance[], +): void { + // Only function components can have effects + if (fiber.tag === WorkTag.FunctionComponent) { + const updateQueue = + fiber.updateQueue as FunctionComponentUpdateQueue | null; + + if (updateQueue !== null && updateQueue.lastEffect !== null) { + const lastEffect = updateQueue.lastEffect; + const firstEffect = lastEffect.next; + + if (firstEffect !== null) { + let effect: Effect | null = firstEffect; + + do { + // Only collect effects that need to run + if ((effect.tag & HookEffectTag.HasEffect) !== 0) { + const instance: EffectInstance = { fiber, effect }; + + if ((effect.tag & HookEffectTag.Passive) !== 0) { + passiveEffects.push(instance); + } else if ((effect.tag & HookEffectTag.Layout) !== 0) { + layoutEffects.push(instance); + } + } + + effect = effect.next; + } while (effect !== null && effect !== firstEffect); + } + } + } + + // Recurse into children + let child = fiber.child; + while (child !== null) { + collectEffectsFromFiber(child, passiveEffects, layoutEffects); + child = child.sibling; + } +} + +// ============================================ +// Effect Execution +// ============================================ + +/** + * Runs all passive effect cleanups. + */ +export function runPassiveEffectCleanups(effects: EffectInstance[]): void { + for (const { effect } of effects) { + const destroy = effect.destroy; + if (destroy !== undefined) { + try { + destroy(); + } catch (error) { + console.error("Error in passive effect cleanup:", error); + } + } + } +} + +/** + * Runs all passive effect creates. + */ +export function runPassiveEffectCreates(effects: EffectInstance[]): void { + for (const { effect } of effects) { + try { + const destroy = effect.create(); + effect.destroy = typeof destroy === "function" ? destroy : undefined; + } catch (error) { + console.error("Error in passive effect:", error); + } + } +} + +/** + * Runs all layout effect cleanups synchronously. + */ +export function runLayoutEffectCleanups(effects: EffectInstance[]): void { + for (const { effect } of effects) { + const destroy = effect.destroy; + if (destroy !== undefined) { + try { + destroy(); + } catch (error) { + console.error("Error in layout effect cleanup:", error); + } + } + } +} + +/** + * Runs all layout effect creates synchronously. + */ +export function runLayoutEffectCreates(effects: EffectInstance[]): void { + for (const { effect } of effects) { + try { + const destroy = effect.create(); + effect.destroy = typeof destroy === "function" ? destroy : undefined; + } catch (error) { + console.error("Error in layout effect:", error); + } + } +} + +// ============================================ +// Effect Dependencies +// ============================================ + +/** + * Checks if effect dependencies have changed. + */ +export function areHookInputsEqual( + nextDeps: readonly unknown[] | null, + prevDeps: readonly unknown[] | null, +): boolean { + if (prevDeps === null || nextDeps === null) { + return false; + } + + if (prevDeps.length !== nextDeps.length) { + return false; + } + + for (let i = 0; i < prevDeps.length; i++) { + if (Object.is(nextDeps[i], prevDeps[i])) { + continue; + } + return false; + } + + return true; +} + +// ============================================ +// Deletion Effects +// ============================================ + +/** + * Collects effects from fibers that are being deleted. + * These effects need their cleanup functions called. + */ +export function collectDeletionEffects( + deletions: Fiber[] | null, +): EffectInstance[] { + if (deletions === null) { + return []; + } + + const effects: EffectInstance[] = []; + + for (const fiber of deletions) { + collectEffectsForDeletion(fiber, effects); + } + + return effects; +} + +/** + * Recursively collects effects from a deleted fiber tree. + */ +function collectEffectsForDeletion( + fiber: Fiber, + effects: EffectInstance[], +): void { + // Only function components can have effects + if (fiber.tag === WorkTag.FunctionComponent) { + const updateQueue = + fiber.updateQueue as FunctionComponentUpdateQueue | null; + + if (updateQueue !== null && updateQueue.lastEffect !== null) { + const lastEffect = updateQueue.lastEffect; + const firstEffect = lastEffect.next; + + if (firstEffect !== null) { + let effect: Effect | null = firstEffect; + + do { + // Collect all effects that have destroy functions + if (effect.destroy !== undefined) { + effects.push({ fiber, effect }); + } + + effect = effect.next; + } while (effect !== null && effect !== firstEffect); + } + } + } + + // Recurse into children + let child = fiber.child; + while (child !== null) { + collectEffectsForDeletion(child, effects); + child = child.sibling; + } +} + +// ============================================ +// Effect Flags +// ============================================ + +/** + * Marks a fiber as having passive effects. + */ +export function markFiberWithPassiveEffect(fiber: Fiber): void { + fiber.flags = createFlags((fiber.flags as number) | (Passive as number)); + + // Bubble up to root + let parent = fiber.return; + while (parent !== null) { + parent.subtreeFlags = createFlags( + (parent.subtreeFlags as number) | (Passive as number), + ); + parent = parent.return; + } +} + +/** + * Checks if a fiber tree has passive effects. + */ +export function doesFiberHavePassiveEffects(fiber: Fiber): boolean { + return ( + ((fiber.flags as number) & (Passive as number)) !== 0 || + ((fiber.subtreeFlags as number) & (Passive as number)) !== 0 + ); +} + +// ============================================ +// Passive Effect Scheduling +// ============================================ + +let pendingPassiveEffects: CollectedEffects | null = null; +let passiveEffectCallbackScheduled = false; + +/** + * Schedules passive effects to run asynchronously. + */ +export function schedulePassiveEffects(effects: CollectedEffects): void { + pendingPassiveEffects = effects; + + if (!passiveEffectCallbackScheduled) { + passiveEffectCallbackScheduled = true; + scheduleCallback(flushPassiveEffects); + } +} + +/** + * Flushes all pending passive effects. + */ +export function flushPassiveEffects(): boolean { + if (pendingPassiveEffects === null) { + return false; + } + + const effects = pendingPassiveEffects; + pendingPassiveEffects = null; + passiveEffectCallbackScheduled = false; + + // First, run cleanups from orphaned/unmounted components (destroy only, no create) + runPassiveEffectCleanups(effects.passiveCleanups); + + // Then run cleanups from active effects (deps changed) + runPassiveEffectCleanups(effects.passiveEffects); + + // Finally, run creates for active effects only (not orphaned ones) + runPassiveEffectCreates(effects.passiveEffects); + + return true; +} + +/** + * Schedules a callback to run asynchronously. + * Uses MessageChannel for better timing than setTimeout. + */ +function scheduleCallback(callback: () => void): void { + if (typeof MessageChannel !== "undefined") { + const channel = new MessageChannel(); + channel.port1.onmessage = () => { + callback(); + }; + channel.port2.postMessage(null); + } else { + // Fallback to setTimeout for macrotask timing (passive effects should run after paint) + setTimeout(callback, 0); + } +} diff --git a/src/fiber/fiberUtils.ts b/src/fiber/fiberUtils.ts new file mode 100644 index 0000000..49c0c10 --- /dev/null +++ b/src/fiber/fiberUtils.ts @@ -0,0 +1,355 @@ +/* **************** */ +/* Fiber Utilities */ +/* **************** */ + +/** + * Utility functions for fiber tree traversal and host operations. + */ + +import { + flagsInclude, + getHostStateNode, + isHostComponentFiber, + isHostPortalFiber, + isHostRootFiber, + isHostTextFiber, +} from "./typeGuards"; +import type { Fiber, FiberRoot } from "./types"; +import { Placement, WorkTag } from "./types"; + +// ============================================ +// Tree Traversal +// ============================================ + +/** + * Gets the next fiber in a DFS traversal. + * Returns the child, then sibling, then uncle, etc. + */ +export function getNextFiber(fiber: Fiber, root: Fiber): Fiber | null { + // First, try to go down to a child + if (fiber.child !== null) { + return fiber.child; + } + + // No child, try siblings and then uncles + let current: Fiber | null = fiber; + while (current !== null) { + if (current === root) { + // We've traversed the entire tree + return null; + } + + if (current.sibling !== null) { + return current.sibling; + } + + // Go up to parent + current = current.return; + } + + return null; +} + +/** + * Gets the next fiber in DFS order for complete phase. + * Traverses siblings before returning to parent. + */ +export function completeUnitOfWork(unitOfWork: Fiber): Fiber | null { + let completedWork: Fiber | null = unitOfWork; + + while (completedWork !== null) { + // Check if there's a sibling to process + const sibling = completedWork.sibling; + if (sibling !== null) { + return sibling; + } + + // Return to parent + completedWork = completedWork.return; + } + + return null; +} + +// ============================================ +// Host Parent/Sibling Finding +// ============================================ + +/** + * Finds the nearest host parent fiber. + * Walks up the tree until it finds a HostComponent, HostRoot, or HostPortal. + */ +export function findHostParent(fiber: Fiber): Fiber | null { + let parent = fiber.return; + + while (parent !== null) { + if ( + parent.tag === WorkTag.HostComponent || + parent.tag === WorkTag.HostRoot || + parent.tag === WorkTag.HostPortal + ) { + return parent; + } + parent = parent.return; + } + + return null; +} + +/** + * Gets the host parent DOM node for a fiber. + * Returns the actual DOM element where children should be appended. + */ +export function getHostParentNode(fiber: Fiber): Element | null { + const hostParentFiber = findHostParent(fiber); + + if (hostParentFiber === null) { + return null; + } + + if (isHostComponentFiber(hostParentFiber)) { + return hostParentFiber.stateNode; + } + + if (isHostRootFiber(hostParentFiber)) { + return hostParentFiber.stateNode.containerInfo; + } + + if (isHostPortalFiber(hostParentFiber)) { + return hostParentFiber.stateNode.containerInfo; + } + + return null; +} + +/** + * Finds the next host sibling for placement. + * This is needed to know where to insertBefore(). + * + * We need to find the next sibling that is a host node. + * This might not be an immediate sibling if there are function components. + */ +export function findHostSibling(fiber: Fiber): Element | Text | null { + let node: Fiber | null = fiber; + + findSibling: while (true) { + // Walk up until we find a sibling + while (node.sibling === null) { + if (node.return === null || isHostParent(node.return)) { + // We're at the root or a host parent, no sibling found + return null; + } + node = node.return; + } + + node = node.sibling; + + // Walk down to find a host node + while (!isHostComponentFiber(node) && !isHostTextFiber(node)) { + // If this is a placement, we can't use it as a sibling + if (flagsInclude(node.flags, Placement)) { + continue findSibling; + } + + // Portals are not part of the regular flow + if (isHostPortalFiber(node)) { + continue findSibling; + } + + // No child, continue searching siblings + if (node.child === null) { + continue findSibling; + } + + // Go down into the child + node = node.child; + } + + // Found a host node + // Check if it's a placement - if so, we can't use it + if (!flagsInclude(node.flags, Placement)) { + return node.stateNode; + } + } +} + +/** + * Checks if a fiber is a host parent (can contain DOM children). + */ +function isHostParent(fiber: Fiber): boolean { + return ( + fiber.tag === WorkTag.HostComponent || + fiber.tag === WorkTag.HostRoot || + fiber.tag === WorkTag.HostPortal + ); +} + +// ============================================ +// Fiber State Helpers +// ============================================ + +/** + * Gets the state node (DOM element) for a fiber. + * Returns null for non-host fibers. + */ +export function getStateNode(fiber: Fiber): Element | Text | null { + return getHostStateNode(fiber); +} + +/** + * Gets the first host child of a fiber. + * Traverses down through function components. + */ +export function getFirstHostChild(fiber: Fiber): Element | Text | null { + let child: Fiber | null = fiber.child; + + while (child !== null) { + if (isHostComponentFiber(child) || isHostTextFiber(child)) { + return child.stateNode; + } + + if (isHostPortalFiber(child)) { + // Skip portals + child = child.sibling; + continue; + } + + if (child.child !== null) { + // Go deeper + child = child.child; + continue; + } + + // No host child found in this branch, try sibling + while (child !== null && child.sibling === null) { + if (child.return === null || child.return === fiber) { + return null; + } + child = child.return; + } + + child = child?.sibling ?? null; + } + + return null; +} + +/** + * Collects all host children of a fiber. + * Used for removal operations. + */ +export function collectHostChildren(fiber: Fiber): (Element | Text)[] { + const children: (Element | Text)[] = []; + let node: Fiber | null = fiber; + + while (true) { + if (isHostComponentFiber(node) || isHostTextFiber(node)) { + children.push(node.stateNode); + } else if (isHostPortalFiber(node)) { + // Skip portal subtrees - they manage their own DOM + } else if (node.child !== null) { + node = node.child; + continue; + } + + if (node === fiber) { + return children; + } + + while (node.sibling === null) { + if (node.return === null || node.return === fiber) { + return children; + } + node = node.return; + } + + node = node.sibling; + } +} + +// ============================================ +// Root Finding +// ============================================ + +/** + * Finds the FiberRoot from any fiber in the tree. + */ +export function findFiberRoot(fiber: Fiber): FiberRoot | null { + let current: Fiber | null = fiber; + + while (current !== null) { + if (isHostRootFiber(current)) { + return current.stateNode; + } + current = current.return; + } + + return null; +} + +/** + * Finds the nearest portal container from a fiber. + * Returns null if not inside a portal. + */ +export function findPortalContainer(fiber: Fiber): Element | null { + let current: Fiber | null = fiber; + + while (current !== null) { + if (isHostPortalFiber(current)) { + return current.stateNode.containerInfo; + } + current = current.return; + } + + return null; +} + +// ============================================ +// Debug Utilities +// ============================================ + +/** + * Gets a debug name for a fiber. + */ +export function getFiberDebugName(fiber: Fiber): string { + switch (fiber.tag) { + case WorkTag.FunctionComponent: + return typeof fiber.type === "function" + ? fiber.type.name || "Anonymous" + : "FunctionComponent"; + case WorkTag.HostRoot: + return "HostRoot"; + case WorkTag.HostComponent: + return typeof fiber.type === "string" ? fiber.type : "HostComponent"; + case WorkTag.HostText: + return "HostText"; + case WorkTag.Fragment: + return "Fragment"; + case WorkTag.HostPortal: + return "Portal"; + case WorkTag.ContextProvider: + return "ContextProvider"; + case WorkTag.ContextConsumer: + return "ContextConsumer"; + case WorkTag.MemoComponent: + return "Memo"; + default: + return `Unknown(${fiber.tag})`; + } +} + +/** + * Prints a fiber tree for debugging. + */ +export function printFiberTree(fiber: Fiber, indent = 0): void { + const prefix = " ".repeat(indent); + const name = getFiberDebugName(fiber); + const key = fiber.key ? ` key="${fiber.key}"` : ""; + console.log(`${prefix}${name}${key}`); + + let child = fiber.child; + while (child !== null) { + printFiberTree(child, indent + 1); + child = child.sibling; + } +}