From 2016c510befd3ff802851b81df59ed9a76e20e0b Mon Sep 17 00:00:00 2001 From: MarcelOlsen Date: Fri, 13 Feb 2026 23:19:26 +0100 Subject: [PATCH] feat: add SSR hydration and resumability support Add hydrateRoot and enterHydrationState for client-side hydration. Add serializeFiberTree and dehydrateRoot for state serialization and resumability. --- src/fiber/hydration.ts | 454 +++++++++++++++++++++++++++++++++ src/fiber/resumability.ts | 519 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 973 insertions(+) create mode 100644 src/fiber/hydration.ts create mode 100644 src/fiber/resumability.ts diff --git a/src/fiber/hydration.ts b/src/fiber/hydration.ts new file mode 100644 index 0000000..47df370 --- /dev/null +++ b/src/fiber/hydration.ts @@ -0,0 +1,454 @@ +/* **************** */ +/* Hydration - SSR Rehydration */ +/* **************** */ + +/** + * Implements SSR hydration for the fiber architecture. + * Attaches event handlers and reconciles with existing DOM. + */ + +import type { AnyMiniReactElement } from "../core/types"; +import { eventSystem } from "../events/eventSystem"; +import { + type SerializedFiberRoot, + createFiberFromSerialized, + parseSerializedRoot, +} from "./resumability"; +import type { Fiber, FiberRoot, Lane } from "./types"; +import { NoFlags, NoLanes, SyncLane, WorkTag, createLanes } from "./types"; +import { scheduleUpdateOnFiber } from "./workLoop"; + +// ============================================ +// Hydration Options +// ============================================ + +/** + * Options for hydrating a root. + */ +export type HydrateRootOptions = { + /** Called when hydration produces a mismatch */ + onRecoverableError?: (error: Error) => void; + /** Serialized state to restore */ + serializedState?: string; + /** Whether to suppress hydration mismatch warnings in development */ + suppressHydrationWarning?: boolean; +}; + +// ============================================ +// Hydration State +// ============================================ + +/** + * Whether we're currently hydrating. + */ +let isHydrating = false; + +/** + * The current hydration target node. + */ +let hydrationTargetNode: Node | null = null; + +/** + * Stack of hydration nodes for nested elements. + */ +const hydrationNodeStack: Node[] = []; + +// ============================================ +// Hydration Entry Points +// ============================================ + +/** + * Creates a hydrating fiber root. + * Attaches to existing server-rendered DOM. + * + * @param container - The container with server-rendered content + * @param element - The React element to hydrate + * @param options - Hydration options + * @returns The fiber root + */ +export function hydrateRoot( + container: Element, + element: AnyMiniReactElement, + options?: HydrateRootOptions, +): FiberRoot { + // Initialize event system + eventSystem.initialize(container); + eventSystem.enableFiberMode(); + + // Create the root fiber + const hostRootFiber: Fiber = { + tag: WorkTag.HostRoot, + key: null, + elementType: null, + type: null, + stateNode: null, + return: null, + child: null, + sibling: null, + index: 0, + ref: null, + refCleanup: null, + pendingProps: { children: element }, + memoizedProps: null, + memoizedState: null, + updateQueue: null, + dependencies: null, + flags: NoFlags, + subtreeFlags: NoFlags, + deletions: null, + lanes: NoLanes, + childLanes: NoLanes, + alternate: null, + }; + + // Create the FiberRoot + const root: FiberRoot = { + tag: 1, // HydrationRoot + containerInfo: container, + current: hostRootFiber, + finishedWork: null, + pendingChildren: element, + pendingLanes: NoLanes, + suspendedLanes: NoLanes, + pingedLanes: NoLanes, + expiredLanes: NoLanes, + finishedLanes: NoLanes, + callbackNode: null, + callbackPriority: 0 as Lane, + expirationTimes: new Map(), + isDehydrated: true, + mutableSourceEagerHydrationData: null, + }; + + // Link them + hostRootFiber.stateNode = root; + + // Restore state if provided + if (options?.serializedState) { + const serialized = parseSerializedRoot(options.serializedState); + if (serialized !== null) { + restoreStateFromSerialized(root, serialized); + } + } + + // Schedule hydration + root.pendingLanes = createLanes( + (root.pendingLanes as number) | (SyncLane as number), + ); + scheduleUpdateOnFiber(root, hostRootFiber, SyncLane); + + return root; +} + +/** + * Restores state from serialized data. + * + * @param root - The fiber root + * @param serialized - The serialized state + */ +function restoreStateFromSerialized( + _root: FiberRoot, + serialized: SerializedFiberRoot, +): void { + // Create a fiber structure from serialized data + // This will be used to restore hook state during hydration + const restoredFiber = createFiberFromSerialized(serialized.root); + + // Store for later use during hydration + hydrationStateMap.set("root", restoredFiber); +} + +/** + * Map for storing restored state during hydration. + */ +const hydrationStateMap = new Map(); + +// ============================================ +// Hydration Phase +// ============================================ + +/** + * Enters hydration mode. + * + * @param container - The container to hydrate + */ +export function enterHydrationState(container: Element): void { + isHydrating = true; + hydrationTargetNode = container.firstChild; +} + +/** + * Exits hydration mode. + */ +export function exitHydrationState(): void { + isHydrating = false; + hydrationTargetNode = null; + hydrationNodeStack.length = 0; + hydrationStateMap.clear(); +} + +/** + * Checks if we're currently hydrating. + */ +export function getIsHydrating(): boolean { + return isHydrating; +} + +// ============================================ +// Hydration Reconciliation +// ============================================ + +/** + * Attempts to hydrate a fiber with an existing DOM node. + * + * @param fiber - The fiber to hydrate + * @returns true if hydration succeeded, false otherwise + */ +export function tryToClaimNextHydratableInstance(fiber: Fiber): boolean { + if (!isHydrating) { + return false; + } + + if (hydrationTargetNode === null) { + // No more nodes to hydrate + return false; + } + + const instance = hydrationTargetNode; + + // Check if the node matches the fiber + if (!canHydrateInstance(fiber, instance)) { + // Mismatch - need to insert a new node + warnOnHydrationMismatch(fiber, instance); + return false; + } + + // Claim this node (cast to Element since we verified it's an element node) + fiber.stateNode = instance as Element; + + // Register with event system + eventSystem.registerFiber(fiber, instance); + + // Register event handlers if the fiber has props + if (fiber.pendingProps) { + eventSystem.hasEventHandlers(fiber.pendingProps as Record); + } + + // Move to next sibling for next hydration + hydrationTargetNode = instance.nextSibling; + + return true; +} + +/** + * Attempts to hydrate a text fiber. + * + * @param fiber - The text fiber to hydrate + * @returns true if hydration succeeded, false otherwise + */ +export function tryToClaimNextHydratableTextInstance(fiber: Fiber): boolean { + if (!isHydrating) { + return false; + } + + if (hydrationTargetNode === null) { + return false; + } + + const instance = hydrationTargetNode; + + // Must be a text node + if (instance.nodeType !== Node.TEXT_NODE) { + return false; + } + + // Claim this node (cast to Text since we verified it's a text node) + fiber.stateNode = instance as Text; + + // Register with event system + eventSystem.registerFiber(fiber, instance); + + // Move to next sibling + hydrationTargetNode = instance.nextSibling; + + return true; +} + +/** + * Checks if an existing DOM node can be used for a fiber. + * + * @param fiber - The fiber to check + * @param instance - The DOM node to check against + * @returns true if the node matches the fiber + */ +function canHydrateInstance(fiber: Fiber, instance: Node): boolean { + switch (fiber.tag) { + case WorkTag.HostComponent: { + if (instance.nodeType !== Node.ELEMENT_NODE) { + return false; + } + const element = instance as Element; + const type = fiber.type as string; + return element.nodeName.toLowerCase() === type.toLowerCase(); + } + + case WorkTag.HostText: { + return instance.nodeType === Node.TEXT_NODE; + } + + default: + return false; + } +} + +/** + * Prepares to hydrate children of a fiber. + * + * @param fiber - The parent fiber + */ +export function prepareToHydrateHostInstance(fiber: Fiber): void { + if (!isHydrating) { + return; + } + + const instance = fiber.stateNode as Element; + if (instance === null) { + return; + } + + // Push current target to stack + if (hydrationTargetNode !== null) { + hydrationNodeStack.push(hydrationTargetNode); + } + + // Set target to first child + hydrationTargetNode = instance.firstChild; +} + +/** + * Completes hydration of a fiber's children. + * + * @param fiber - The parent fiber + */ +export function popHydrationState(fiber: Fiber): void { + if (!isHydrating) { + return; + } + + // Check for extra children that weren't hydrated + if (hydrationTargetNode !== null) { + warnOnExtraChildren(fiber, hydrationTargetNode); + } + + // Pop from stack + if (hydrationNodeStack.length > 0) { + hydrationTargetNode = hydrationNodeStack.pop() ?? null; + } else { + hydrationTargetNode = null; + } +} + +// ============================================ +// Hydration Warnings +// ============================================ + +/** + * Warns when there's a hydration mismatch. + */ +function warnOnHydrationMismatch(fiber: Fiber, instance: Node): void { + if (process.env.NODE_ENV !== "production") { + const fiberType = + fiber.type !== null ? String(fiber.type) : `tag:${fiber.tag}`; + const instanceType = + instance.nodeType === Node.ELEMENT_NODE + ? (instance as Element).tagName.toLowerCase() + : `#${instance.nodeType}`; + + console.warn( + `Hydration mismatch: Expected ${fiberType} but found ${instanceType}`, + ); + } +} + +/** + * Warns when there are extra children not in the fiber tree. + */ +function warnOnExtraChildren(fiber: Fiber, _node: Node): void { + if (process.env.NODE_ENV !== "production") { + const parentType = + fiber.type !== null ? String(fiber.type) : `tag:${fiber.tag}`; + console.warn( + `Hydration: Found extra child node in ${parentType}. Server rendered more nodes than expected.`, + ); + } +} + +// ============================================ +// State Restoration +// ============================================ + +/** + * Gets restored state for a fiber during hydration. + * + * @param fiber - The fiber to get state for + * @param key - The component key + * @returns Restored memoized state or null + */ +export function getRestoredState( + fiber: Fiber, + key: string | null, +): unknown | null { + const restoredRoot = hydrationStateMap.get("root"); + if (restoredRoot === null || restoredRoot === undefined) { + return null; + } + + // Find matching fiber in restored tree + const match = findMatchingFiber(restoredRoot, fiber, key); + if (match !== null) { + return match.memoizedState; + } + + return null; +} + +/** + * Finds a matching fiber in the restored tree. + * + * @param restoredFiber - The restored fiber tree + * @param targetFiber - The fiber to match + * @param key - The component key + * @returns Matching fiber or null + */ +function findMatchingFiber( + restoredFiber: Fiber, + targetFiber: Fiber, + key: string | null, +): Fiber | null { + // Check if this fiber matches + if ( + restoredFiber.tag === targetFiber.tag && + restoredFiber.key === key && + restoredFiber.type === targetFiber.type + ) { + return restoredFiber; + } + + // Search children + let child = restoredFiber.child; + while (child !== null) { + const match = findMatchingFiber(child, targetFiber, key); + if (match !== null) { + return match; + } + child = child.sibling; + } + + return null; +} + +// ============================================ +// Exports +// ============================================ + +export { isHydrating, hydrationTargetNode }; diff --git a/src/fiber/resumability.ts b/src/fiber/resumability.ts new file mode 100644 index 0000000..684455b --- /dev/null +++ b/src/fiber/resumability.ts @@ -0,0 +1,519 @@ +/* **************** */ +/* Resumability - Fiber Serialization */ +/* **************** */ + +/** + * Implements fiber tree serialization for resumability. + * Enables SSR hydration and state persistence. + */ + +import type { Fiber, FiberRoot } from "./types"; +import { NoFlags, NoLanes, type WorkTag } from "./types"; + +// ============================================ +// Serialized Types +// ============================================ + +/** + * Serialized representation of a fiber node. + * Contains only the data needed to reconstruct the fiber tree. + */ +export type SerializedFiber = { + /** The fiber tag (WorkTag) */ + tag: number; + /** Element key for reconciliation */ + key: string | null; + /** Element type (tag name or function reference key) */ + type: string | null; + /** Fiber index in sibling list */ + index: number; + /** Pending props (excluding functions) */ + pendingProps: Record | null; + /** Memoized state (for hooks) */ + memoizedState: unknown; + /** Child fibers */ + children: SerializedFiber[]; +}; + +/** + * Serialized representation of a fiber root. + */ +export type SerializedFiberRoot = { + /** Version for compatibility checking */ + version: number; + /** Timestamp of serialization */ + timestamp: number; + /** The root fiber tree */ + root: SerializedFiber; + /** Pending lanes */ + pendingLanes: number; +}; + +// ============================================ +// Serialization Version +// ============================================ + +const SERIALIZATION_VERSION = 1; + +// ============================================ +// Serialization Functions +// ============================================ + +/** + * Serializes a fiber tree for storage or transfer. + * Excludes non-serializable data like DOM nodes and functions. + * + * @param root - The FiberRoot to serialize + * @returns Serialized fiber root + */ +export function serializeFiberTree(root: FiberRoot): SerializedFiberRoot { + const serializedRoot = serializeFiber(root.current); + + return { + version: SERIALIZATION_VERSION, + timestamp: Date.now(), + root: serializedRoot, + pendingLanes: root.pendingLanes as number, + }; +} + +/** + * Serializes a single fiber node and its children. + * + * @param fiber - The fiber to serialize + * @returns Serialized fiber + */ +function serializeFiber(fiber: Fiber): SerializedFiber { + // Serialize children + const children: SerializedFiber[] = []; + let child = fiber.child; + while (child !== null) { + children.push(serializeFiber(child)); + child = child.sibling; + } + + // Filter props to remove non-serializable values + const serializableProps = filterSerializableProps(fiber.pendingProps); + + // Serialize memoized state (for hooks) + const serializableState = serializeHookState(fiber.memoizedState); + + return { + tag: fiber.tag, + key: fiber.key, + type: getSerializableType(fiber), + index: fiber.index, + pendingProps: serializableProps, + memoizedState: serializableState, + children, + }; +} + +/** + * Filters props to only include serializable values. + * Removes functions, symbols, and other non-JSON-serializable types. + * + * @param props - The props object to filter + * @returns Serializable props + */ +function filterSerializableProps( + props: Record | null, +): Record | null { + if (props === null) { + return null; + } + + const result: Record = {}; + + for (const [key, value] of Object.entries(props)) { + // Skip children (handled separately in fiber tree) + if (key === "children") { + continue; + } + + // Skip functions and symbols + if (typeof value === "function" || typeof value === "symbol") { + continue; + } + + // Skip non-serializable objects (DOM nodes, etc.) + if (value instanceof Node || value instanceof Event) { + continue; + } + + // Handle arrays + if (Array.isArray(value)) { + const serializedArray = value.filter( + (item) => typeof item !== "function" && typeof item !== "symbol", + ); + if (serializedArray.length > 0) { + result[key] = serializedArray; + } + continue; + } + + // Handle nested objects + if (typeof value === "object" && value !== null) { + const serializedObj = filterSerializableProps( + value as Record, + ); + if (serializedObj !== null && Object.keys(serializedObj).length > 0) { + result[key] = serializedObj; + } + continue; + } + + // Include primitives + result[key] = value; + } + + return Object.keys(result).length > 0 ? result : null; +} + +/** + * Gets a serializable type identifier for a fiber. + * + * @param fiber - The fiber to get type for + * @returns Serializable type string or null + */ +function getSerializableType(fiber: Fiber): string | null { + if (fiber.type === null) { + return null; + } + + // Host components use their tag name + if (typeof fiber.type === "string") { + return fiber.type; + } + + // Function components - use name or generate ID + if (typeof fiber.type === "function") { + return fiber.type.name || "__anonymous__"; + } + + // Symbols (Fragment, Portal, etc.) + if (typeof fiber.type === "symbol") { + return fiber.type.toString(); + } + + return null; +} + +/** + * Serializes hook state from a fiber. + * Handles the linked list structure of hooks. + * + * @param memoizedState - The hook state to serialize + * @returns Serializable state + */ +function serializeHookState(memoizedState: unknown): unknown { + if (memoizedState === null || memoizedState === undefined) { + return null; + } + + // Check if this is a hook linked list (has memoizedState and next properties) + if ( + typeof memoizedState === "object" && + "memoizedState" in (memoizedState as Record) && + "next" in (memoizedState as Record) + ) { + const hooks: unknown[] = []; + let current = memoizedState as { memoizedState: unknown; next: unknown }; + + while (current !== null) { + // Serialize each hook's state + const hookState = serializeHookValue(current.memoizedState); + hooks.push(hookState); + current = current.next as { memoizedState: unknown; next: unknown }; + } + + return { __hooks__: hooks }; + } + + // Simple value + return serializeHookValue(memoizedState); +} + +/** + * Serializes a single hook value. + * + * @param value - The value to serialize + * @returns Serializable value + */ +function serializeHookValue(value: unknown): unknown { + if (value === null || value === undefined) { + return null; + } + + // Skip functions + if (typeof value === "function") { + return { __type__: "function" }; + } + + // Handle refs + if ( + typeof value === "object" && + "current" in (value as Record) + ) { + const refValue = (value as { current: unknown }).current; + // Don't serialize DOM refs + if (refValue instanceof Node) { + return { __type__: "ref", value: null }; + } + return { __type__: "ref", value: serializeHookValue(refValue) }; + } + + // Handle arrays (like useMemo/useCallback deps) + if (Array.isArray(value)) { + // Check if it's a [value, deps] tuple + if (value.length === 2 && Array.isArray(value[1])) { + return { + __type__: "memoized", + value: serializeHookValue(value[0]), + deps: value[1].map(serializeHookValue), + }; + } + return value.map(serializeHookValue); + } + + // Handle effects (skip - they'll be re-run on hydration) + if ( + typeof value === "object" && + "tag" in (value as Record) && + "create" in (value as Record) + ) { + return { __type__: "effect" }; + } + + // Primitives and plain objects + if (typeof value === "object") { + try { + // Test if it's JSON serializable + JSON.stringify(value); + return value; + } catch { + return { __type__: "unserializable" }; + } + } + + return value; +} + +// ============================================ +// JSON Conversion +// ============================================ + +/** + * Converts a fiber root to a JSON string. + * + * @param root - The FiberRoot to convert + * @returns JSON string + */ +export function dehydrateRoot(root: FiberRoot): string { + const serialized = serializeFiberTree(root); + return JSON.stringify(serialized); +} + +/** + * Parses a serialized fiber root from JSON. + * + * @param json - The JSON string to parse + * @returns Serialized fiber root or null if invalid + */ +export function parseSerializedRoot(json: string): SerializedFiberRoot | null { + try { + const parsed = JSON.parse(json) as SerializedFiberRoot; + + // Version check + if (parsed.version !== SERIALIZATION_VERSION) { + console.warn( + `Serialization version mismatch: expected ${SERIALIZATION_VERSION}, got ${parsed.version}`, + ); + return null; + } + + return parsed; + } catch (error) { + console.error("Failed to parse serialized fiber root:", error); + return null; + } +} + +// ============================================ +// State Extraction +// ============================================ + +/** + * Extracts component state from a serialized fiber tree. + * Useful for debugging or state inspection. + * + * @param serialized - The serialized fiber root + * @returns Map of component keys to their state + */ +export function extractComponentState( + serialized: SerializedFiberRoot, +): Map { + const stateMap = new Map(); + + function extractFromFiber(fiber: SerializedFiber, path: string): void { + const componentPath = + fiber.key !== null ? `${path}/${fiber.key}` : `${path}/${fiber.index}`; + + if (fiber.memoizedState !== null) { + stateMap.set(componentPath, fiber.memoizedState); + } + + fiber.children.forEach((child, index) => { + extractFromFiber(child, `${componentPath}[${index}]`); + }); + } + + extractFromFiber(serialized.root, "root"); + return stateMap; +} + +// ============================================ +// Deserialization Utilities +// ============================================ + +/** + * Creates a minimal fiber structure from serialized data. + * This is used during hydration to restore state. + * + * @param serialized - The serialized fiber + * @returns Partial fiber structure + */ +export function createFiberFromSerialized(serialized: SerializedFiber): Fiber { + const fiber: Fiber = { + tag: serialized.tag as WorkTag, + key: serialized.key, + elementType: serialized.type, + type: serialized.type, + stateNode: null, + return: null, + child: null, + sibling: null, + index: serialized.index, + ref: null, + refCleanup: null, + pendingProps: serialized.pendingProps ?? {}, + memoizedProps: serialized.pendingProps, + memoizedState: deserializeHookState(serialized.memoizedState), + updateQueue: null, + dependencies: null, + flags: NoFlags, + subtreeFlags: NoFlags, + deletions: null, + lanes: NoLanes, + childLanes: NoLanes, + alternate: null, + }; + + // Reconstruct children + let previousChild: Fiber | null = null; + for (const childSerialized of serialized.children) { + const child = createFiberFromSerialized(childSerialized); + child.return = fiber; + + if (previousChild === null) { + fiber.child = child; + } else { + previousChild.sibling = child; + } + previousChild = child; + } + + return fiber; +} + +/** + * Deserializes hook state back to usable form. + * + * @param serialized - The serialized state + * @returns Deserialized state + */ +function deserializeHookState(serialized: unknown): unknown { + if (serialized === null || serialized === undefined) { + return null; + } + + // Handle hooks array marker + if ( + typeof serialized === "object" && + "__hooks__" in (serialized as Record) + ) { + const hooks = (serialized as { __hooks__: unknown[] }).__hooks__; + // Reconstruct hook linked list + let first: { memoizedState: unknown; next: unknown } | null = null; + let previous: { memoizedState: unknown; next: unknown } | null = null; + + for (const hookState of hooks) { + const hook = { + memoizedState: deserializeHookValue(hookState), + baseState: null, + baseQueue: null, + queue: null, + next: null, + }; + + if (first === null) { + first = hook; + } + if (previous !== null) { + previous.next = hook; + } + previous = hook; + } + + return first; + } + + return deserializeHookValue(serialized); +} + +/** + * Deserializes a single hook value. + * + * @param serialized - The serialized value + * @returns Deserialized value + */ +function deserializeHookValue(serialized: unknown): unknown { + if (serialized === null || serialized === undefined) { + return null; + } + + if (typeof serialized !== "object") { + return serialized; + } + + const obj = serialized as Record; + + // Handle typed markers + if ("__type__" in obj) { + switch (obj["__type__"]) { + case "function": + // Can't restore functions - return a no-op + return () => {}; + case "ref": + return { current: obj["value"] }; + case "memoized": + return [ + deserializeHookValue(obj["value"]), + (obj["deps"] as unknown[]).map(deserializeHookValue), + ]; + case "effect": + // Effects will be re-run on mount + return null; + case "unserializable": + return null; + } + } + + // Handle arrays + if (Array.isArray(serialized)) { + return serialized.map(deserializeHookValue); + } + + // Plain objects + return serialized; +}