Repository: deno
Issue: Microtask queue is not handled correctly in Deno
Labels: bug,web

In browsers, a microtask checkpoint is performed whenever the JS stack empties. Take the following example:

```js
const filereader = new FileReader();

filereader.onloadstart = () => {
  console.log("loadstart event");
  queueMicrotask(() => console.log("loadstart microtask"));
};
filereader.onloadend = () => {
  console.log("loadend event");
  queueMicrotask(() => console.log("loadend microtask"));
};
filereader.onload = () => {
  console.log("load event");
  queueMicrotask(() => console.log("load microtask"));
};
filereader.onprogress = () => {
  console.log("progress event");
  queueMicrotask(() => console.log("progress microtask"));
};

filereader.readAsText(new Blob(["hello"]));
queueMicrotask(() => console.log("after start microtask"));
```

In all current browsers, `FileReader` is implemented as a native object. That means when you call `filereader.readAsText`, the following happens:

1. When calling `filereader.readAsText`, JS calls into native C++ code and queues some tasks (things that should happen on future spins of the event loop) to emit the `loadstart`, `progress`, `load`, and `loadend` events in that order. The C++ code returns back into JS. 
2. The `queueMicrotask` call pushes one microtask onto the microtask queue.
3. At this point there is no more JS to be executed, and the JS stack is empty. Thus a microtask checkpoint is performed. `after start microtask` is printed to the console. The microtask queue is now empty.
4. In C++ code, the event loop takes the first ready task from the task queue. The task is "dispatching the loadstart event". The event is dispatched. This calls from C++ into JS. Something is now on the JS stack. `loadstart event` is logged, and a microtask is queued.
5. There is no more JS to be executed, and the JS stack is empty. Thus a microtask checkpoint is performed. `loadstart microtask` is  printed to the console. The microtask queue is now empty.
6. The same happens for the progress, load, and loadend events.

This is how the browser console looks after executing this:

```
after start microtask
loadstart event
loadstart microtask
progress event
progress microtask
load event
load microtask
loadend event
loadend microtask
```

Now lets see what happens in Deno. We currently also follow the rule that a microtask checkpoint is performed whenever the JS stack empties. In Deno `FileReader` is implemented in JS code:

1. When calling `filereader.readAsText`, JS calls into JS code and queues some tasks emit the `loadstart`, `progress`, `load`, and `loadend` events in that order. The JS code returns back to the calling JS code.
2. The `queueMicrotask` call pushes one microtask onto the microtask queue.
3. At this point there is no more JS to be executed, and the JS stack is empty. Thus a microtask checkpoint is performed. `after start microtask` is printed to the console. The microtask queue is now empty.
4. In JS the first ready task is taken from the task queue. Something is already on the JS stack. The task is "dispatching the loadstart event". The event is dispatched. This calls JS from JS. `loadstart event` is logged, and a microtask is queued. At this point there is still something on the JS stack, so a microtask checkpoint is not performed.
5. The same happens for the progress, load, and loadend events.
6. Now there is no more JS to be executed, and the JS stack is empty. Thus a microtask checkpoint is performed. `loadstart microtask`, `progress microtask`, `load microtask`, and `loadend microtask` are  printed to the console. The microtask queue is now empty.

The console looks like this:

```
after start microtask
loadstart event
progress event
loadstart microtask
progress microtask
load event
loadend event
load microtask
loadend microtask
```
 
> Note: if you run this in the latest build from main, it will actually print something different. This is due to a bug which I have already fixed on a seperate branch. Consider the above output to be canonical.

The problem is that for purposes of V8s automatic microtask checkpointing, which happens when the JS queue empties, there is no distinction between JS code that is part of Deno's internal implementation, and user code. For microtask checkpointing to work correctly, this needs to be fixed.

The naive approach to fixing this is to just force V8 to run a microtask checkpoint whenever user code that was called by "internal JS" returns execution back to that internal JS. For example, we could microtask checkpoint after every event handler insider a dispatchEvent is called.

This has a problem though, that I can demonstrate with the below example:

```js
const target = new EventTarget();

target.addEventListener("foo", () => {
  console.log("foo event");
  queueMicrotask(() => console.log("foo microtask"));
});
target.addEventListener("bar", () => {
  console.log("bar event");
  queueMicrotask(() => console.log("bar microtask"));
});

target.dispatchEvent(new Event("foo"));
target.dispatchEvent(new Event("bar"));
```

With our naive approach, we would dispatch event foo, call the event handler for foo, which will print "foo event", and then perform a microtask checkpoint which causes "foo microtask" to be printed. The same would happen for the "bar" event:

```
foo event
foo microtask
bar event
bar microtask
```

If you look at what this does in the browser however, you will see the following printed:

```
foo event
bar event
foo microtask
bar microtask
```

Here you can see that our naive approach broke down. When a user calls `dispatchEvent` there is still some JS on the stack. The internal JS code directly calls the event handler. The internal code is sandwiched between two pieces of user code. In this case we do not want to perform a microtask checkpoint when the user JS code returns to the internal JS code, because there is still something on the JS stack (the `dispatchEvent` call). Because of this the microtask checkpoint is deferred until after _both_ event handlers have been called, and there is no more JS to be executed.

So how do we solve this? Instead of naively performing a microtask checkpoint every time some user JS code returns to the internal JS code, we instead perform that checkpoint only if there is no user js code on the stack. If the internal JS code is sandwiched between user JS code, no microtask checkpoint would be performed. The difficulty is figuring out if we are sandwiched or not in a performant way.