public/js/lib/loop-model.js
// A model of the HTML event loop (§12, §81): it "executes" a snippet
// described as a list of operations and records a snapshot after every
// step: the call stack, timers held by the browser, the task queue, the
// microtask queue, and the console.
//
// Operations (`at` is a substring of the source line, for highlighting):
// { at, log: "text" }
// { at, timeout: { delay, label, body } } setTimeout(fn, delay)
// { at, then: { label, body, next? } } Promise.resolve().then(fn)[.then(next)]
// { at, micro: { label, body } } queueMicrotask(fn)
// { at, call: { name, body } } fn() — sync call (async fns too)
// { at, await: { label, rest } } await <settled value>: the rest of the
// function becomes one microtask
export function simulate(ops, source = "") {
const lines = source.split("\n");
const lineOf = (at) => (at ? lines.findIndex((l) => l.includes(at)) : -1);
const S = { stack: [], webapis: [], tasks: [], micro: [], console: [], now: 0 };
const steps = [];
let seq = 0;
const snap = (kind, note, at) =>
steps.push({
kind,
note,
line: lineOf(at),
stack: [...S.stack],
webapis: S.webapis.map((t) => `${t.label} (${t.delay}ms)`),
tasks: S.tasks.map((t) => t.label),
micro: S.micro.map((m) => m.label),
console: [...S.console],
});
function exec(body) {
for (const op of body) {
if ("log" in op) {
S.console.push(op.log);
snap("log", `console.log prints "${op.log}"`, op.at);
} else if (op.timeout) {
const t = op.timeout;
S.webapis.push({ ...t, due: S.now + t.delay, seq: seq++ });
snap("webapi", `setTimeout hands "${t.label}" to the browser's timer (${t.delay}ms). JS moves on immediately.`, op.at);
} else if (op.then) {
S.micro.push(op.then);
snap("micro", `The promise is already resolved, so "${op.then.label}" goes straight onto the microtask queue.`, op.at);
} else if (op.micro) {
S.micro.push(op.micro);
snap("micro", `queueMicrotask puts "${op.micro.label}" on the microtask queue.`, op.at);
} else if (op.call) {
S.stack.push(`${op.call.name}()`);
snap("push", `Calling ${op.call.name}() pushes a new frame onto the call stack.`, op.at);
exec(op.call.body);
S.stack.pop();
snap("pop", `${op.call.name}() returns: its frame is popped.`, op.at);
} else if (op.await) {
S.micro.push({ label: op.await.label, body: op.await.rest });
snap("micro", `await suspends the function. Its continuation "${op.await.label}" is queued as a microtask.`, op.at);
return; // the rest of this body runs later, as that microtask
}
}
}
function drainMicrotasks() {
if (!S.micro.length) return;
snap("checkpoint", "The call stack is empty: microtask checkpoint. Drain the WHOLE microtask queue, including microtasks queued along the way.");
while (S.micro.length) {
const m = S.micro.shift();
S.stack.push(m.label);
snap("run-micro", `Run microtask "${m.label}".`, m.at);
exec(m.body);
S.stack.pop();
if (m.next) {
S.micro.push(m.next);
snap("micro", `"${m.label}" returned, which resolves the next promise in the chain: "${m.next.label}" is queued.`);
} else {
snap("pop", `Microtask "${m.label}" done.`);
}
}
}
function moveExpiredTimers() {
const due = S.webapis.filter((t) => t.due <= S.now).sort((a, b) => a.due - b.due || a.seq - b.seq);
for (const t of due) {
S.webapis.splice(S.webapis.indexOf(t), 1);
S.tasks.push(t);
snap("task-queued", `Timer "${t.label}" expired: its callback joins the task (macrotask) queue.`);
}
}
S.stack.push("<script>");
snap("task", "The whole <script> is the first task. It runs to completion on the call stack.");
exec(ops);
S.stack.pop();
snap("pop", "The script is done and the call stack is empty.");
drainMicrotasks();
for (let guard = 0; guard < 500; guard++) {
moveExpiredTimers();
if (!S.tasks.length) {
if (!S.webapis.length) break;
S.now = Math.min(...S.webapis.map((t) => t.due));
continue;
}
const task = S.tasks.shift();
S.stack.push(task.label);
snap("task", `The event loop takes ONE task from the task queue: "${task.label}".`);
exec(task.body);
S.stack.pop();
snap("pop", `Task "${task.label}" done.`);
drainMicrotasks();
}
snap("idle", "Every queue is empty. The event loop sleeps until the next event, timer or network response.");
return { steps, output: S.console };
}