GalleryExhibit 06

Two presses of Enter, one question you never saw

A delay before advancing to the next blank left the form live, so a second Enter queued a second timer and skipped a question entirely.

Guided coding exerciseStateConcurrencyReactsetTimeoutKeyboard inputVitest

A teaching exercise that builds up a piece of code one fill-in-the-blank at a time.

What you can check here. Both handlers run over an injected clock, so “two timers were queued” is asserted directly rather than inferred from where the counter stopped.

The object

See it happen

Exhibit 06 — BrokenGuided coding exercise
function total(prices) {
____ sum = 0;
for (const price ____ prices) {
sum += price;
}
____ sum;
}

Blank 1 of 3 — The running total changes inside the loop, so it is declared with the block-scoped keyword …

Fill the answer, then press Enter twice as fast as you can.

Timer queue

emptyempty
timers queued0
blank shown1/3
blanks skipped0

Nothing yet — try a control above.

The exercise in the case is a short three-blank stand-in written for this museum, not the original exercise. It runs both submit handlers, with the advance delay shortened from 1400ms to 700ms so the demonstration is not tedious, and draws the timer queue as it fills.

What you are looking at

Every Enter during the celebration window queues another advance.

  • Answer the blank, then press Enter twice quickly.
  • Watch the timer queue grow and the blank counter jump by two.
  • Notice which question you never got asked.

Wall text

What happened

The exercise asks you to fill in missing pieces of a program. Get one right and the explanation stays on screen for about a second and a half before the next blank slides in — long enough to actually read it.

The form stayed interactive for that window. The input still had focus, the submit handler was still wired up, and the answer you had just typed was still in the box and still correct.

So a second Enter — key repeat, an impatient double tap, a habit learned from ordinary web forms — passed the same check again and scheduled a second setTimeout. Both fired. The step counter advanced twice, and the next blank was answered by nobody and shown to nobody.

Root cause

The delay is a piece of state that nothing was tracking. Between the answer and the advance, the component is in a real mode — “celebrating, about to move on” — and the code represented it only as a timer id nobody held on to.

The fix is to hold on to it. A ref stores the pending timer; a non-null ref means “in transition” and every entry point returns early while it is set. Restarting clears it, and an unmount effect clears it too, so leaving the page cannot schedule a state update into a component that is gone.

A ref is the right home for the guard, for two reasons. Restart and unmount need the timer id to cancel it, so it has to live somewhere that survives renders without causing one. And a ref takes effect the moment it is written, so two calls that arrive inside a single event — as they do when this case’s “Press Enter twice” button calls the handler twice — are both stopped. A state flag would have stopped two separate key presses, because React commits the first key press’s update before it handles the second; it would not stop two calls made within one event.

The change

Code

The mutexminimal reproduction
+  // While this holds a timer id, an advance is on its way and every
+  // way of moving on is closed.
+  const pendingAdvance = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  function queueAdvance(delay: number) {
+    if (pendingAdvance.current !== null) return;
+    pendingAdvance.current = setTimeout(() => {
+      pendingAdvance.current = null;
+      setStep((n) => n + 1);
+      setMessage(null);
+    }, delay);
+  }
 
   function onSubmit(event: FormEvent) {
     event.preventDefault();
+    if (pendingAdvance.current !== null) return;
     if (!isCorrect(input, blank)) return showHint();
     setMessage(blank.why);
-    setTimeout(() => {
-      setStep((n) => n + 1);
-      setMessage(null);
-    }, 1400);
+    queueAdvance(1400);
   }
And the timer that outlived the pageminimal reproduction
useEffect(() => () => {
  // Leaving the exercise cancels an advance that has not happened yet.
  if (pendingAdvance.current !== null) clearTimeout(pendingAdvance.current);
}, []);

The test that catches it

Proof it stays fixed

The obvious assertion — “the step counter ended up at 1” — is the wrong one. It would still pass if the second timer had been scheduled and happened to be harmless, and the second timer is the defect.

So lib/sims/double-submit.ts takes its clock as a parameter. The tests hand it a queue that runs nothing until flushed, which makes “how many advances are pending right now” something you can assert on directly, without waiting.

The broken cases carry their weight: two submits must queue two timers, and after flushing, the blank between them must never appear in the list of blanks the visitor was shown. Under the fix the same input queues one, and Reveal is blocked during the window too. Restart and unmount are covered for both handlers: the fix cancels the one advance it holds, while the broken handler, which never kept a timer id, has nothing to cancel, and every advance it queued still fires.

tests/unit/sims/double-submit.test.tsfrom this repository · source
it("skips the blank between the two advances", () => {
  const { runner, pending, flush } = setup("broken");
  answer(runner);
  runner.submit();
  runner.submit();
  flush();
 
  expect(runner.state().solved).toBe(2);
  expect(runner.state().seen).not.toContain(1);
  expect(pending()).toBe(0);
});

How it went

Discovery to regression test

  1. Discovered

    A blank nobody was asked

    Pressing Enter twice after a correct answer skipped past the following blank. Reproducible by holding the key down, which is how it was most likely being hit in practice.

    The simulation ↗
  2. Final fix

    A ref that means “in transition”

    One pending timer at a time, checked by every entry point, cancelled on restart, and cleared on unmount.

    lib/sims/double-submit.ts ↗
  3. Pinned by a test

    Assert the queue, not the outcome

    The clock is injected, so the tests assert how many advances are pending rather than what the counter settled on, for both handlers.

    tests/unit/sims/double-submit.test.ts ↗

Verify it yourself

Sources