From acd4d80376f92f3f864bc4f72786b32768f208c2 Mon Sep 17 00:00:00 2001 From: MarcelOlsen Date: Fri, 13 Feb 2026 23:18:46 +0100 Subject: [PATCH] feat: add fiber scheduler and work-in-progress tree management Add time-slicing scheduler with priority levels and shouldYield. Add WIP tree management with prepareFreshStack and createWorkInProgressFiber for double-buffering. --- src/fiber/scheduler.ts | 496 ++++++++++++++++++++++++++++++++++++ src/fiber/workInProgress.ts | 308 ++++++++++++++++++++++ 2 files changed, 804 insertions(+) create mode 100644 src/fiber/scheduler.ts create mode 100644 src/fiber/workInProgress.ts diff --git a/src/fiber/scheduler.ts b/src/fiber/scheduler.ts new file mode 100644 index 0000000..c4a2d66 --- /dev/null +++ b/src/fiber/scheduler.ts @@ -0,0 +1,496 @@ +/* **************** */ +/* Fiber Scheduler - Time Slicing */ +/* **************** */ + +/** + * Implements time-sliced rendering with interruptibility. + * Uses MessageChannel for scheduling and performance.now() for timing. + */ + +import type { Lane, Lanes } from "./types"; +import { DefaultLane, IdleLane, SyncLane, createLane } from "./types"; + +// ============================================ +// Priority Levels +// ============================================ + +/** + * Priority levels for scheduling. + * Using const object pattern. + */ +export const Priority = { + ImmediatePriority: 1, + UserBlockingPriority: 2, + NormalPriority: 3, + LowPriority: 4, + IdlePriority: 5, +} as const satisfies Record; + +export type Priority = (typeof Priority)[keyof typeof Priority]; + +// ============================================ +// Scheduler Configuration +// ============================================ + +/** + * Frame yield interval in milliseconds. + * React uses ~5ms to leave time for browser painting at 60fps. + */ +const FRAME_YIELD_INTERVAL = 5; + +/** + * Maximum time to yield for idle work. + */ +const IDLE_YIELD_INTERVAL = 50; + +// ============================================ +// Scheduler State +// ============================================ + +/** + * Current deadline for yielding. + */ +let deadline = 0; + +/** + * Whether there's pending work scheduled. + */ +let isMessageLoopRunning = false; + +/** + * The callback to execute during the message loop. + */ +let scheduledCallback: + | ((hasTimeRemaining: boolean, currentTime: number) => boolean) + | null = null; + +/** + * MessageChannel for scheduling. + */ +let channel: MessageChannel | null = null; + +/** + * Current priority level. + */ +let currentPriorityLevel: Priority = Priority.NormalPriority; + +/** + * Maps priority to timeout in milliseconds. + */ +const PRIORITY_TIMEOUT: Record = { + [Priority.ImmediatePriority]: -1, // Sync + [Priority.UserBlockingPriority]: 250, + [Priority.NormalPriority]: 5000, + [Priority.LowPriority]: 10000, + [Priority.IdlePriority]: 1073741823, // Max int32 (never expires) +}; + +// ============================================ +// Time Functions +// ============================================ + +/** + * Gets the current time. + */ +export function getCurrentTime(): number { + if ( + typeof performance !== "undefined" && + typeof performance.now === "function" + ) { + return performance.now(); + } + return Date.now(); +} + +/** + * Checks if we should yield to the browser. + */ +export function shouldYield(): boolean { + const currentTime = getCurrentTime(); + return currentTime >= deadline; +} + +/** + * Checks if we should yield for a specific priority. + */ +export function shouldYieldForPriority(priority: Priority): boolean { + if (priority <= Priority.ImmediatePriority) { + return false; // Never yield for immediate priority + } + return shouldYield(); +} + +/** + * Calculates the deadline based on priority. + */ +function calculateDeadline(priority: Priority): number { + const currentTime = getCurrentTime(); + const timeout = + priority === Priority.IdlePriority + ? IDLE_YIELD_INTERVAL + : FRAME_YIELD_INTERVAL; + return currentTime + timeout; +} + +// ============================================ +// Task Scheduling +// ============================================ + +/** + * Task node for the scheduler queue. + */ +type Task = { + id: number; + callback: + | ((hasTimeRemaining: boolean, currentTime: number) => boolean | null) + | null; + priorityLevel: Priority; + startTime: number; + expirationTime: number; + sortIndex: number; +}; + +/** + * Task ID counter. + */ +let taskIdCounter = 0; + +/** + * Task queue (min-heap by sortIndex). + */ +const taskQueue: Task[] = []; + +/** + * Schedules a callback with the given priority. + */ +export function scheduleCallback( + priorityLevel: Priority, + callback: (hasTimeRemaining: boolean, currentTime: number) => boolean | null, +): Task { + const currentTime = getCurrentTime(); + const startTime = currentTime; + const timeout = PRIORITY_TIMEOUT[priorityLevel]; + const expirationTime = startTime + timeout; + + const task: Task = { + id: taskIdCounter++, + callback, + priorityLevel, + startTime, + expirationTime, + sortIndex: expirationTime, + }; + + // Add to queue (simple push, should use heap for real implementation) + taskQueue.push(task); + taskQueue.sort((a, b) => a.sortIndex - b.sortIndex); + + // Start the message loop if not already running + if (!isMessageLoopRunning) { + isMessageLoopRunning = true; + schedulePerformWorkUntilDeadline(); + } + + return task; +} + +/** + * Cancels a scheduled task. + */ +export function cancelCallback(task: Task): void { + // Mark the callback as null to cancel + task.callback = null; +} + +/** + * Gets the current priority level. + */ +export function getCurrentPriorityLevel(): Priority { + return currentPriorityLevel; +} + +/** + * Runs a callback with a specific priority. + */ +export function runWithPriority( + priorityLevel: Priority, + callback: () => T, +): T { + const previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = priorityLevel; + + try { + return callback(); + } finally { + currentPriorityLevel = previousPriorityLevel; + } +} + +// ============================================ +// Message Loop +// ============================================ + +/** + * Schedules the performWorkUntilDeadline function. + */ +function schedulePerformWorkUntilDeadline(): void { + if (typeof MessageChannel !== "undefined") { + if (channel === null) { + channel = new MessageChannel(); + channel.port1.onmessage = performWorkUntilDeadline; + } + channel.port2.postMessage(null); + } else { + // Fallback to setTimeout + setTimeout(performWorkUntilDeadline, 0); + } +} + +/** + * Performs work until the deadline. + */ +function performWorkUntilDeadline(): void { + if (scheduledCallback !== null) { + const currentTime = getCurrentTime(); + deadline = currentTime + FRAME_YIELD_INTERVAL; + + const hasTimeRemaining = true; + let hasMoreWork = true; + + try { + hasMoreWork = scheduledCallback(hasTimeRemaining, currentTime); + } finally { + if (hasMoreWork) { + schedulePerformWorkUntilDeadline(); + } else { + isMessageLoopRunning = false; + scheduledCallback = null; + } + } + } else { + isMessageLoopRunning = false; + } + + // Process the task queue + processTaskQueue(); +} + +/** + * Processes tasks from the queue. + */ +function processTaskQueue(): void { + const currentTime = getCurrentTime(); + + while (taskQueue.length > 0) { + const task = taskQueue[0]!; + + if (task.callback === null) { + // Task was cancelled + taskQueue.shift(); + continue; + } + + if (task.startTime > currentTime) { + // Task is delayed, stop processing + break; + } + + // Calculate deadline for this task + deadline = calculateDeadline(task.priorityLevel); + + const callback = task.callback; + currentPriorityLevel = task.priorityLevel; + + const continuationCallback = callback(true, currentTime); + + if (typeof continuationCallback === "function") { + // Task yielded and wants to continue + task.callback = continuationCallback as ( + hasTimeRemaining: boolean, + currentTime: number, + ) => boolean | null; + } else { + // Task completed + taskQueue.shift(); + } + + // Check if we should yield + if (shouldYield() && taskQueue.length > 0) { + // More work to do but we should yield + if (!isMessageLoopRunning) { + isMessageLoopRunning = true; + schedulePerformWorkUntilDeadline(); + } + break; + } + } + + currentPriorityLevel = Priority.NormalPriority; +} + +// ============================================ +// Lane to Priority Mapping +// ============================================ + +/** + * Converts a lane to a scheduler priority. + */ +export function lanesToSchedulerPriority(lanes: Lanes): Priority { + // Get the highest priority lane + const lane = getHighestPriorityLane(lanes); + + if (lane === SyncLane) { + return Priority.ImmediatePriority; + } + + if ((lane as number) <= (DefaultLane as number)) { + return Priority.UserBlockingPriority; + } + + if ((lane as number) <= (IdleLane as number)) { + return Priority.NormalPriority; + } + + return Priority.IdlePriority; +} + +/** + * Converts a scheduler priority to a lane. + */ +export function schedulerPriorityToLane(priority: Priority): Lane { + switch (priority) { + case Priority.ImmediatePriority: + return SyncLane; + case Priority.UserBlockingPriority: + return DefaultLane; + case Priority.NormalPriority: + return DefaultLane; + case Priority.LowPriority: + return IdleLane; + case Priority.IdlePriority: + return IdleLane; + default: + return DefaultLane; + } +} + +/** + * Gets the highest priority lane from a lanes bitmask. + */ +function getHighestPriorityLane(lanes: Lanes): Lane { + // Get the rightmost bit (highest priority) + return createLane((lanes as number) & -(lanes as number)); +} + +// ============================================ +// Concurrent Work Loop +// ============================================ + +/** + * Flag indicating if we're working concurrently. + */ +let workInProgressIsConcurrent = false; + +/** + * Sets the concurrent mode flag. + */ +export function setWorkInProgressConcurrent(isConcurrent: boolean): void { + workInProgressIsConcurrent = isConcurrent; +} + +/** + * Checks if we're in concurrent mode. + */ +export function isWorkInProgressConcurrent(): boolean { + return workInProgressIsConcurrent; +} + +/** + * Requests the current event time. + */ +export function requestEventTime(): number { + return getCurrentTime(); +} + +/** + * Requests a lane for an update. + */ +export function requestUpdateLane(): Lane { + // For now, always use sync lane + return SyncLane; +} + +// ============================================ +// Idle Scheduling +// ============================================ + +/** + * Schedules work to run during idle time. + */ +export function scheduleIdleCallback( + callback: (deadline: { + didTimeout: boolean; + timeRemaining: () => number; + }) => void, +): number { + if (typeof requestIdleCallback !== "undefined") { + return requestIdleCallback(callback); + } + + // Fallback implementation + const start = getCurrentTime(); + return setTimeout(() => { + callback({ + didTimeout: false, + timeRemaining: () => + Math.max(0, IDLE_YIELD_INTERVAL - (getCurrentTime() - start)), + }); + }, 0) as unknown as number; +} + +/** + * Cancels an idle callback. + */ +export function cancelIdleCallback(id: number): void { + if ( + typeof window !== "undefined" && + typeof window.cancelIdleCallback !== "undefined" + ) { + window.cancelIdleCallback(id); + } else { + clearTimeout(id); + } +} + +// ============================================ +// Force Flush +// ============================================ + +/** + * Forces all pending work to flush synchronously. + */ +export function flushWork(): boolean { + const previousPriorityLevel = currentPriorityLevel; + currentPriorityLevel = Priority.ImmediatePriority; + + try { + while (taskQueue.length > 0) { + const task = taskQueue[0]!; + + if (task.callback === null) { + taskQueue.shift(); + continue; + } + + const callback = task.callback; + const result = callback(true, getCurrentTime()); + + if (typeof result !== "function") { + taskQueue.shift(); + } + } + return true; + } finally { + currentPriorityLevel = previousPriorityLevel; + } +} diff --git a/src/fiber/workInProgress.ts b/src/fiber/workInProgress.ts new file mode 100644 index 0000000..57793cc --- /dev/null +++ b/src/fiber/workInProgress.ts @@ -0,0 +1,308 @@ +/* **************** */ +/* Work In Progress Tree Management */ +/* **************** */ + +/** + * Utilities for managing the work-in-progress (WIP) tree. + * React uses double buffering: current tree (on screen) and WIP tree (being built). + */ + +import type { Fiber, FiberRoot } from "./types"; +import { NoFlags, NoLanes, createLanes } from "./types"; + +// ============================================ +// WIP Tree Globals +// ============================================ + +/** + * The root of the tree we're working on. + */ +let workInProgressRoot: FiberRoot | null = null; + +/** + * The fiber we're currently working on. + */ +let workInProgress: Fiber | null = null; + +/** + * The lanes we're currently rendering. + */ +let workInProgressRootRenderLanes = 0; + +// ============================================ +// WIP Getters/Setters +// ============================================ + +/** + * Gets the current work-in-progress fiber. + */ +export function getWorkInProgress(): Fiber | null { + return workInProgress; +} + +/** + * Sets the current work-in-progress fiber. + */ +export function setWorkInProgress(fiber: Fiber | null): void { + workInProgress = fiber; +} + +/** + * Gets the current work-in-progress root. + */ +export function getWorkInProgressRoot(): FiberRoot | null { + return workInProgressRoot; +} + +/** + * Sets the current work-in-progress root. + */ +export function setWorkInProgressRoot(root: FiberRoot | null): void { + workInProgressRoot = root; +} + +/** + * Gets the render lanes for the current WIP tree. + */ +export function getWorkInProgressRootRenderLanes(): number { + return workInProgressRootRenderLanes; +} + +/** + * Sets the render lanes for the current WIP tree. + */ +export function setWorkInProgressRootRenderLanes(lanes: number): void { + workInProgressRootRenderLanes = lanes; +} + +// ============================================ +// WIP Tree Operations +// ============================================ + +/** + * Prepares a fresh stack for a new render. + * Called at the start of renderRoot. + */ +export function prepareFreshStack(root: FiberRoot, lanes: number): Fiber { + // Reset the WIP root + root.finishedWork = null; + root.finishedLanes = NoLanes; + + // Set the current root + workInProgressRoot = root; + workInProgressRootRenderLanes = lanes; + + // Create the WIP fiber from current + const rootWorkInProgress = createWorkInProgressFiber(root.current, {}); + workInProgress = rootWorkInProgress; + + return rootWorkInProgress; +} + +/** + * Creates a work-in-progress fiber from a current fiber. + * This is the core double-buffering mechanism. + */ +export function createWorkInProgressFiber( + current: Fiber, + pendingProps: Record, +): Fiber { + let wip = current.alternate; + + if (wip === null) { + // Create a new WIP fiber + wip = { + tag: current.tag, + key: current.key, + elementType: current.elementType, + type: current.type, + stateNode: current.stateNode, + return: null, + child: null, + sibling: null, + index: 0, + ref: current.ref, + refCleanup: current.refCleanup, + pendingProps, + memoizedProps: null, + memoizedState: current.memoizedState, + updateQueue: current.updateQueue, + dependencies: current.dependencies, + flags: NoFlags, + subtreeFlags: NoFlags, + deletions: null, + lanes: current.lanes, + childLanes: current.childLanes, + alternate: current, + }; + + // Link the alternates + current.alternate = wip; + } else { + // Reuse the existing WIP fiber + wip.pendingProps = pendingProps; + wip.type = current.type; + + // Reset effects + wip.flags = NoFlags; + wip.subtreeFlags = NoFlags; + wip.deletions = null; + + // Reset child pointer for fresh reconciliation + // (children will be reconciled from current.child) + } + + // Copy over fields that need to persist + wip.childLanes = current.childLanes; + wip.lanes = current.lanes; + wip.child = current.child; + wip.memoizedProps = current.memoizedProps; + wip.memoizedState = current.memoizedState; + wip.updateQueue = current.updateQueue; + wip.dependencies = current.dependencies; + + return wip; +} + +/** + * Resets a WIP fiber for a fresh render attempt. + */ +export function resetWorkInProgressFiber( + wip: Fiber, + renderLanes: number, +): void { + // Clear effects + wip.flags = NoFlags; + wip.subtreeFlags = NoFlags; + wip.deletions = null; + + // Reset lanes to render lanes + wip.lanes = createLanes(renderLanes); + wip.childLanes = NoLanes; +} + +/** + * Clones the WIP child fibers from current. + * Used when bailing out but still needing to clone children. + */ +export function cloneChildFibers( + _current: Fiber | null, + workInProgress: Fiber, +): void { + if (workInProgress.child === null) { + return; + } + + // Clone the first child + let currentChild = workInProgress.child; + let newChild = createWorkInProgressFiber( + currentChild, + currentChild.pendingProps, + ); + workInProgress.child = newChild; + newChild.return = workInProgress; + + // Clone siblings + while (currentChild.sibling !== null) { + currentChild = currentChild.sibling; + const newSibling = createWorkInProgressFiber( + currentChild, + currentChild.pendingProps, + ); + newChild.sibling = newSibling; + newSibling.return = workInProgress; + newChild = newSibling; + } +} + +// ============================================ +// Commit Tree Swap +// ============================================ + +/** + * Finalizes the WIP tree as the new current tree. + * Called during commitRoot. + */ +export function finishConcurrentRender( + root: FiberRoot, + finishedWork: Fiber, + lanes: number, +): void { + root.finishedWork = finishedWork; + root.finishedLanes = createLanes(lanes); +} + +/** + * Swaps the current tree with the WIP tree. + * This happens atomically in commitRoot. + */ +export function commitTreeSwap(root: FiberRoot): void { + const finishedWork = root.finishedWork; + if (finishedWork === null) { + return; + } + + // The finished work becomes the new current + root.current = finishedWork; + + // Clear the finished work reference + root.finishedWork = null; + root.finishedLanes = NoLanes; + + // Reset WIP globals + workInProgressRoot = null; + workInProgress = null; + workInProgressRootRenderLanes = 0; +} + +// ============================================ +// Bailout Utilities +// ============================================ + +/** + * Checks if we can bail out of updating a fiber. + * Bailout means we can reuse the current fiber without re-rendering. + */ +export function checkIfWorkInProgressReceivedUpdate(): boolean { + // This would check if the fiber received an update + // For now, always return false (no optimization) + return false; +} + +/** + * Marks that the work-in-progress received an update. + */ +let didReceiveUpdate = false; + +export function markWorkInProgressReceivedUpdate(): void { + didReceiveUpdate = true; +} + +export function resetDidReceiveUpdate(): void { + didReceiveUpdate = false; +} + +export function getDidReceiveUpdate(): boolean { + return didReceiveUpdate; +} + +// ============================================ +// Debug Utilities +// ============================================ + +/** + * Returns debugging info about the current WIP state. + */ +export function getWorkInProgressDebugInfo(): { + hasRoot: boolean; + hasFiber: boolean; + lanes: number; + fiberTag: number | null; +} { + return { + hasRoot: workInProgressRoot !== null, + hasFiber: workInProgress !== null, + lanes: workInProgressRootRenderLanes, + fiberTag: workInProgress?.tag ?? null, + }; +}