const queue = [];
/**
* Membership index for {@link queue}.
*
* Deduplication used to be `queue.includes(job)`, a linear scan on every
* enqueue. That was unnoticeable when a queue held one job per updating
* component. Fine-grained rendering puts one job per *binding* in it, so a
* component with 500 bound values turned a flush into 125,000 comparisons
* before running a single job. The set is kept exactly in step with the array:
* anything that removes from one removes from the other.
* @type {Set<Function>}
*/
const queued = new Set();
const flushCallbacks = [];
let isPending = false;
let isFlushing = false;
import { logger } from '../runtime/AvenxLogger.js';
import { AvenxErrorCodes, formatMessage } from '../runtime/AvenxError.js';
import { tracer } from '../trace/tracer.js';
/**
* Maximum recursive flush depth allowed before triggering reactive deadlock abort.
* Defaults to 25 to accommodate deep legitimate multi-pass updates while stopping infinite cycles.
* @type {number}
*/
let maxFlushCount = 25;
/**
* Recursion depth of the currently executing flush.
*
* Incremented once per {@link flushJobs} invocation, not once per job: a flush
* pass legitimately drains arbitrarily many jobs (one per updating component),
* so only *re-entry* — a pass whose jobs queued yet more work — indicates a
* potential runaway update chain.
* @type {number}
*/
let flushDepth = 0;
/**
* Execution count of individual jobs in the current flush cycle.
* Keyed by job or job.id.
* @type {Map<any, number>}
*/
const jobExecutionCounts = new Map();
/**
* Ordered log of jobs executed during the current flush for deadlock cycle diagnosis.
* @type {Array<{id: any, name: string, job: Function}>}
*/
const executionHistory = [];
/**
* Registered deadlock event callbacks.
* @type {Set<Function>}
*/
const deadlockHandlers = new Set();
/**
* Configures the maximum allowed flush cycle count.
* @param {number} count - Maximum flush iterations.
*/
export function setSchedulerMaxFlushCount(count) {
if (typeof count === 'number' && count > 0) {
maxFlushCount = count;
}
}
/**
* Returns the currently configured maximum flush cycle count.
* @returns {number}
*/
export function getSchedulerMaxFlushCount() {
return maxFlushCount;
}
/**
* Registers a global callback for scheduler deadlock events.
* @param {function({cyclePath: string, jobs: Function[], boundary: any}): void} handler
* @returns {Function} Unsubscribe function.
*/
export function onSchedulerDeadlock(handler) {
if (typeof handler === 'function') {
deadlockHandlers.add(handler);
return () => deadlockHandlers.delete(handler);
}
return () => {};
}
/**
* Resets the scheduler state (primarily used in testing).
*/
export function resetScheduler() {
queue.length = 0;
queued.clear();
flushCallbacks.length = 0;
isPending = false;
isFlushing = false;
flushDepth = 0;
jobExecutionCounts.clear();
executionHistory.length = 0;
}
/**
* Queues a job (update callback) to be executed in the next microtask.
* Deduplicates multiple calls to the same job.
* @param {Function} job - The callback to run.
*/
export function queueJob(job) {
if (!queued.has(job)) {
if (tracer.on) {
// Component updates are batched into a microtask, so by the time this job
// runs the causal stack is empty and the resulting DOM patch would look
// like an unexplained root. Stamping the job with whatever queued it lets
// the flush below resume that scope. A job already in the queue keeps its
// first cause: that is the mutation that actually scheduled the work.
job.__avenxTraceCause = tracer.current();
}
queue.push(job);
queued.add(job);
queueFlush();
}
}
/**
* Queues a callback to run after the current flush cycle has finished.
* @param {Function} cb - The callback to run.
*/
export function queueFlushCallback(cb) {
if (!flushCallbacks.includes(cb)) {
flushCallbacks.push(cb);
queueFlush();
}
}
/**
* Schedules a flush cycle in a deferred microtask.
*/
function queueFlush() {
if (!isPending && !isFlushing) {
isPending = true;
Promise.resolve().then(() => {
Promise.resolve().then(flushJobs);
});
}
}
/**
* Orders jobs by their `id` ascending so parent components (lower uid) update
* before their children.
* @param {Function} a
* @param {Function} b
* @returns {number}
*/
function byJobId(a, b) {
return (a.id || 0) - (b.id || 0);
}
/**
* Reconstructs the circular dependency chain from the execution history.
* @param {any} recurringId - The job ID that repeated excessively.
* @returns {string} Formatted cycle string (e.g. "Counter -> Stats -> Counter").
*/
function extractCycleChain(recurringId) {
const ids = executionHistory.map((item) => String(item.name || item.id || 'anonymous'));
const targetName = String(recurringId);
const firstIdx = ids.indexOf(targetName);
if (firstIdx !== -1) {
const cycleSub = ids.slice(firstIdx);
if (!cycleSub.endsWith || cycleSub[cycleSub.length - 1] !== targetName) {
cycleSub.push(targetName);
}
return cycleSub.join(' -> ');
}
// Fallback: take last 4 execution steps
const recent = ids.slice(-4);
if (recent.length > 0) {
return recent.join(' -> ');
}
return String(recurringId);
}
/**
* Handles a detected reactive deadlock: logs diagnostics, notifies handlers, and purges the queue.
* @param {any} [triggeringJobId] - The job ID that triggered the cycle.
*/
function handleDeadlock(triggeringJobId) {
const cycleStr = extractCycleChain(triggeringJobId || 'reactive-loop');
const boundaryInfo = '';
const diagnosticMsg = formatMessage(
AvenxErrorCodes.REACTIVE_DEADLOCK_DETECTED,
boundaryInfo,
` ${cycleStr}\n\nExecution aborted to prevent browser freeze.`
);
logger.error(diagnosticMsg);
// Notify any registered deadlock handlers
const eventPayload = {
cyclePath: cycleStr,
triggeringJobId,
executionHistory: [...executionHistory],
};
for (const handler of deadlockHandlers) {
try {
handler(eventPayload);
} catch (e) {
logger.error('Error executing deadlock handler:', e);
}
}
// Clear queued jobs to prevent runaway loop
queue.length = 0;
queued.clear();
}
/**
* Flushes all queued jobs in a loop until the queue is completely empty.
* Jobs are strictly ordered by their `id` property ascending (e.g. component uid)
* to ensure parent components update before their child components.
* After jobs are flushed, all queued flush callbacks (e.g. nextTick) are executed.
*/
function flushJobs() {
isPending = false;
isFlushing = true;
if (flushDepth === 0) {
jobExecutionCounts.clear();
executionHistory.length = 0;
}
flushDepth++;
try {
// Guard 1: Overall flush recursion ceiling. Only re-entry is counted here —
// draining many jobs in a single pass is normal for an application with
// many components reacting to the same tick and must never be aborted.
if (flushDepth > maxFlushCount) {
handleDeadlock('MAX_FLUSH_COUNT_EXCEEDED');
return;
}
// 1. Flush jobs first.
// Sort jobs by their id ascending (e.g. parent uid < child uid).
queue.sort(byJobId);
while (queue.length > 0) {
const job = queue.shift();
queued.delete(job);
const jobId = job.id !== undefined ? job.id : job;
const jobName = job.name || (typeof jobId === 'string' || typeof jobId === 'number' ? `Job#${jobId}` : 'anonymous');
// Frequency is counted per job *function*, not per `job.id`.
//
// `id` orders a flush so a parent component updates before its children,
// and every binding job in one component shares that component's id. If
// the counter were keyed by id, a component with more than ten bound
// values would trip the cycle guard on its very first render -- ten
// unrelated bindings doing their one job each would look identical to one
// job running ten times. Identity is what the guard actually means.
const currentCount = (jobExecutionCounts.get(job) || 0) + 1;
jobExecutionCounts.set(job, currentCount);
executionHistory.push({ id: jobId, name: jobName, job });
// Guard 2: Single job frequency limit. A job re-entering the queue many
// times within one flush session is the real signature of a circular
// update chain (e.g. component A updates B which updates A again).
const perJobLimit = Math.min(10, maxFlushCount);
if (currentCount > perJobLimit) {
handleDeadlock(jobName);
break;
}
const pendingBefore = queue.length;
// Jobs are stable per-component function objects, so the stamp is read
// and cleared rather than left behind, where it would mis-attribute a
// later run to a cause that has nothing to do with it.
let causeToken = -1;
if (tracer.on && job.__avenxTraceCause !== undefined) {
causeToken = tracer.continueFrom(job.__avenxTraceCause);
job.__avenxTraceCause = undefined;
}
try {
job();
} catch (error) {
logger.error('Error executing scheduled job:', error);
} finally {
if (causeToken >= 0) {
tracer.leave(causeToken);
}
}
// Jobs queued while this one ran must be ordered into the remaining drain
// so parents still update before their children.
if (queue.length > pendingBefore) {
queue.sort(byJobId);
}
}
// 2. Flush callbacks
const callbacks = flushCallbacks.slice();
flushCallbacks.length = 0;
for (const cb of callbacks) {
try {
cb();
} catch (error) {
logger.error('Error executing flush callback:', error);
}
}
// 3. Re-flush if executing callbacks or jobs queued more jobs or callbacks.
// The recursion ceiling above stops a runaway chain on re-entry.
if (queue.length > 0 || flushCallbacks.length > 0) {
flushJobs();
}
} finally {
flushDepth--;
if (flushDepth === 0) {
isFlushing = false;
jobExecutionCounts.clear();
executionHistory.length = 0;
// Work left over after an aborted chain still deserves a fresh flush
// rather than sitting in the queue until the next unrelated mutation.
if (queue.length > 0 || flushCallbacks.length > 0) {
queueFlush();
}
}
}
}
/**
* Executes a callback (or resolves a Promise) after all currently queued
* jobs in the scheduler queue have finished flushing.
* @param {Function} [callback] - Optional callback to invoke after the flush.
* @returns {Promise<void>|void} A promise resolving after the flush, if no callback was given.
*/
export function nextTick(callback) {
if (callback) {
queueFlushCallback(callback);
return;
}
return new Promise((resolve) => {
queueFlushCallback(resolve);
});
}