Frontend Labs
⋯

public/js/stations/eventloop.js

// Station 3: the event loop (§12–§15, §81). Each snippet exists twice:
// as real code (`run`) and as a model (`ops`). The model drives the
// animation; "Run it for real" executes `run` and checks they agree.
import { h, station, panel, button, sleep, storage } from "../lib/dom.js";
import { missions, missionList } from "../lib/missions.js";
import { simulate } from "../lib/loop-model.js";

export const SNIPPETS = [
  {
    id: "basics",
    title: "Sync, microtasks, tasks",
    run: (console) => {
      console.log("script start");
      setTimeout(() => console.log("timeout"), 0);
      Promise.resolve()
        .then(() => console.log("promise 1"))
        .then(() => console.log("promise 2"));
      console.log("script end");
    },
    ops: [
      { at: '"script start"', log: "script start" },
      { at: "setTimeout", timeout: { delay: 0, label: "timeout cb", body: [{ at: '"timeout"', log: "timeout" }] } },
      { at: ".then(() => console.log(\"promise 1\"))", then: { label: "then: promise 1", body: [{ at: '"promise 1"', log: "promise 1" }],
        next: { label: "then: promise 2", body: [{ at: '"promise 2"', log: "promise 2" }] } } },
      { at: '"script end"', log: "script end" },
    ],
  },
  {
    id: "await",
    title: "async / await",
    run: (console) => {
      async function load() {
        console.log("load() starts");
        await null;
        console.log("load() resumes");
      }
      console.log("start");
      load();
      queueMicrotask(() => console.log("microtask"));
      console.log("end");
    },
    ops: [
      { at: 'console.log("start")', log: "start" },
      { at: "load();", call: { name: "load", body: [
        { at: '"load() starts"', log: "load() starts" },
        { at: "await null", await: { label: "load() continuation", rest: [{ at: '"load() resumes"', log: "load() resumes" }] } },
      ] } },
      { at: "queueMicrotask", micro: { label: "microtask cb", body: [{ at: 'log("microtask")', log: "microtask" }] } },
      { at: 'console.log("end")', log: "end" },
    ],
  },
  {
    id: "nested",
    title: "Microtasks inside tasks",
    run: (console) => {
      setTimeout(() => {
        console.log("timeout 1");
        Promise.resolve().then(() => console.log("promise in timeout 1"));
      }, 0);
      setTimeout(() => console.log("timeout 2"), 0);
      queueMicrotask(() => {
        console.log("microtask 1");
        queueMicrotask(() => console.log("microtask 2"));
      });
      console.log("sync");
    },
    ops: [
      { at: "setTimeout(() => {", timeout: { delay: 0, label: "timeout 1 cb", body: [
        { at: '"timeout 1"', log: "timeout 1" },
        { at: "Promise.resolve().then", then: { label: "then in timeout 1", body: [{ at: '"promise in timeout 1"', log: "promise in timeout 1" }] } },
      ] } },
      { at: '"timeout 2"', timeout: { delay: 0, label: "timeout 2 cb", body: [{ at: '"timeout 2"', log: "timeout 2" }] } },
      { at: "queueMicrotask(() => {", micro: { label: "microtask 1 cb", body: [
        { at: '"microtask 1"', log: "microtask 1" },
        { at: 'queueMicrotask(() => console.log("microtask 2"))', micro: { label: "microtask 2 cb", body: [{ at: '"microtask 2"', log: "microtask 2" }] } },
      ] } },
      { at: '"sync"', log: "sync" },
    ],
  },
  {
    id: "chain",
    title: "await on an async function, timers with delays",
    run: (console) => {
      async function a() {
        console.log("a: before await");
        await b();
        console.log("a: after await");
      }
      async function b() {
        console.log("b runs");
      }
      setTimeout(() => console.log("timeout 10ms"), 10);
      setTimeout(() => console.log("timeout 0ms"), 0);
      a();
      Promise.resolve().then(() => console.log("then"));
      console.log("sync");
    },
    ops: [
      { at: '"timeout 10ms"', timeout: { delay: 10, label: "10ms cb", body: [{ at: '"timeout 10ms"', log: "timeout 10ms" }] } },
      { at: '"timeout 0ms"', timeout: { delay: 0, label: "0ms cb", body: [{ at: '"timeout 0ms"', log: "timeout 0ms" }] } },
      { at: "  a();", call: { name: "a", body: [
        { at: '"a: before await"', log: "a: before await" },
        { at: "await b()", call: { name: "b", body: [{ at: '"b runs"', log: "b runs" }] } },
        { at: "await b()", await: { label: "a() continuation", rest: [{ at: '"a: after await"', log: "a: after await" }] } },
      ] } },
      { at: "Promise.resolve().then", then: { label: "then cb", body: [{ at: 'log("then")', log: "then" }] } },
      { at: 'console.log("sync")', log: "sync" },
    ],
  },
];

// The real snippet's source, from Function.prototype.toString (which returns
// the exact source text), minus the wrapper and the common indentation.
export function sourceOf(fn) {
  const lines = fn.toString().split("\n").slice(1, -1);
  const indent = Math.min(...lines.filter((l) => l.trim()).map((l) => l.match(/^ */)[0].length));
  return lines.map((l) => l.slice(indent)).join("\n");
}

// Run the real code with a console that records instead of printing.
export async function runForReal(snippet) {
  const out = [];
  snippet.run({ log: (s) => out.push(s) });
  await sleep(60); // long enough for every timer in the snippets to fire
  return out;
}

const score = storage("browser-lab:quiz", {});

function snippetView(snippet) {
  const source = sourceOf(snippet.run);
  const { steps, output } = simulate(snippet.ops, source);
  let i = 0;
  let timer = null;

  const code = h("pre", { class: "code", "aria-label": "Snippet source" }, source.split("\n").map((l) => h("span", {}, l || " ")));
  const box = (cls, title) => h("div", { class: `box ${cls}` }, h("h4", {}, title), h("div", { class: "items" }));
  const boxes = {
    stack: box("stack", "Call stack"),
    webapis: box("webapis", "Web APIs (timers)"),
    micro: box("micro", "Microtask queue"),
    tasks: box("tasks", "Task queue"),
    console: box("console", "Console"),
  };
  const note = h("div", { class: "loop-note", "aria-live": "polite" });
  const wheel = h("div", { class: "wheel", title: "the event loop" });
  const counter = h("span", { class: "note mono" });

  const render = () => {
    const s = steps[i];
    // Patch the tokens in place: re-inserting a node restarts its CSS
    // animation, so kept tokens stay put and only new arrivals animate.
    const fill = (key, items) => {
      const holder = boxes[key].querySelector(".items");
      const pool = [...holder.children];
      const wanted = items.map((t) => {
        const i = pool.findIndex((n) => n.textContent === t);
        return i === -1 ? h("div", { class: "token" }, t) : pool.splice(i, 1)[0];
      });
      pool.forEach((n) => n.remove()); // tokens that left this box
      wanted.forEach((n, i) => {
        if (holder.children[i] !== n) holder.insertBefore(n, holder.children[i] ?? null);
      });
    };
    fill("stack", s.stack);
    fill("webapis", s.webapis);
    fill("micro", s.micro);
    fill("tasks", s.tasks);
    fill("console", s.console);
    [...code.children].forEach((ln, n) => ln.classList.toggle("hl", n === s.line));
    note.textContent = s.note;
    wheel.classList.toggle("spin", s.kind === "task" || s.kind === "checkpoint");
    counter.textContent = `step ${i + 1} / ${steps.length}`;
    prev.disabled = i === 0;
    next.disabled = i === steps.length - 1;
  };
  const go = (n) => {
    i = Math.max(0, Math.min(steps.length - 1, n));
    render();
  };
  const stop = () => {
    clearInterval(timer);
    timer = null;
    playBtn.textContent = "▶ Play";
  };
  const prev = button("◀ Back", () => (stop(), go(i - 1)));
  const next = button("Step ▶", () => (stop(), go(i + 1)));
  const playBtn = button("▶ Play", () => {
    if (timer) return stop();
    if (i === steps.length - 1) go(0);
    playBtn.textContent = "⏸ Pause";
    timer = setInterval(() => (i === steps.length - 1 ? stop() : go(i + 1)), 1100);
  }, { class: "primary" });

  // --- prediction quiz
  const picks = [];
  const answer = h("div", { class: "answer", "aria-live": "polite" });
  const shuffled = [...output].sort(() => Math.random() - 0.5);
  const quiz = h("div", { class: "quiz" },
    shuffled.map((line) => {
      const b = button(line, () => {
        if (b.classList.contains("picked")) return;
        b.classList.add("picked");
        picks.push(line);
        answer.textContent = picks.map((p, n) => `${n + 1}. ${p}`).join("   ");
        if (picks.length === output.length) {
          const right = picks.every((p, n) => p === output[n]);
          answer.append(h("div", { class: right ? "ok" : "bad" }, right ? "✔ Correct! Now step through to see why." : `✘ Not quite. Actual: ${output.join(" → ")}`));
          const s = score.get();
          s[snippet.id] = right;
          score.set(s);
          if (right) missions.complete("eventloop", "predict");
        }
      });
      return b;
    }));
  const resetQuiz = button("reset", () => {
    picks.length = 0;
    answer.textContent = "";
    quiz.querySelectorAll("button").forEach((b) => b.classList.remove("picked"));
  }, { class: "link" });

  // --- run for real
  const real = h("div", { class: "answer" });
  const realBtn = button("🧪 Run it for real", async () => {
    const out = await runForReal(snippet);
    const match = out.join("|") === output.join("|");
    real.replaceChildren(h("div", {}, `Real:  ${out.join(" → ")}`), h("div", {}, `Model: ${output.join(" → ")}`),
      h("div", { class: match ? "ok" : "bad", "data-match": String(match) }, match ? "✔ The model matches your browser." : "✘ Mismatch: your engine disagrees with the model!"));
    if (match) missions.complete("eventloop", "verify");
  });

  render();
  const el = h("div", { class: "loop-grid" },
    h("div", {},
      code,
      panel("1. Predict the console output", h("p", { class: "note" }, "Click the lines in the order you think they print."), quiz, resetQuiz, answer),
      panel("3. Check against your browser", realBtn, real)),
    h("div", {},
      h("div", { class: "row" }, wheel, prev, playBtn, next, counter),
      note,
      h("div", { class: "boxes" }, boxes.stack, boxes.webapis, boxes.micro, boxes.tasks),
      boxes.console));
  return { el, stop };
}

export function mount() {
  const holder = h("div", {});
  let current = null;
  const tabs = h("div", { class: "row", role: "tablist" });
  const select = (s) => {
    current?.stop();
    current = snippetView(s);
    holder.replaceChildren(panel(`2. Step through: ${s.title}`, current.el));
    tabs.querySelectorAll("button").forEach((b) => b.setAttribute("aria-selected", String(b.dataset.id === s.id)));
  };
  tabs.append(...SNIPPETS.map((s) => {
    const b = button(s.title, () => select(s), { role: "tab", "data-id": s.id });
    return b;
  }));
  select(SNIPPETS[0]);
  return {
    el: station(
      "The event loop",
      "One thread, one call stack, two queues that matter. After every task the engine drains the entire microtask queue, and only then takes the next task. Predict, step through, then run the real code to check.",
      missionList("eventloop"),
      tabs,
      holder,
      panel("The rules, in one place",
        h("ol", {},
          h("li", {}, "Run one task (a script, a timer callback, an event handler) to completion. Nothing interrupts it."),
          h("li", {}, "When the call stack empties, drain ALL microtasks: promise reactions, await continuations, queueMicrotask. Microtasks queued meanwhile run too."),
          h("li", {}, "If a frame is due: run requestAnimationFrame callbacks, then style → layout → paint."),
          h("li", {}, "Take the next task. Repeat forever.")),
        h("p", { class: "note" }, "Why setTimeout(fn, 0) waits: it is a task, and tasks only run after the current task and all microtasks. Nested timers are clamped to ≥4ms after 5 levels, and background tabs throttle timers to ≥1s."))),
    unmount: () => current?.stop(),
  };
}

Generated from SOURCE.md · 8 Oct 2026 16:36