Why is there a difference in the task/microtask execution order when a button is programmatically clicked vs DOM clicked?

Fascinating question.

First, the easy part: When you call click, it's a synchronous call triggering all of the event handlers on the button. You can see that if you add logging around the call:

const btn = document.querySelector('#btn');

btn.addEventListener("click", function() {
  Promise.resolve().then(function() { console.log('resolved-1'); });
  console.log('click-1');
});

btn.addEventListener("click", function() {
  Promise.resolve().then(function() { console.log('resolved-2'); });
  console.log('click-2');
});


document.getElementById("btn-simulate").addEventListener("click", function() {
  console.log("About to call click");
  btn.click();
  console.log("Done calling click");
});
<input type="button" id="btn" value="Direct Click">
<input type="button" id="btn-simulate" value="Call click()">

Since the handlers are run synchronously, microtasks are processed only after both handlers have finished. Processing them sooner would require breaking JavaScript's run-to-completion semantics.

In contrast, when the event is dispatched via the DOM, it's more interesting: Each handler is invoked. Invoking a handler includes cleaning up after running script, which includes doing a microtask checkpoint, running any pending microtasks. So microtasks scheduled by the handler that was invoked get run before the next handler gets run.

That's "why" they're different in one sense: Because the handler callbacks are called synchronously, in order, when you use click(), and so there's no opportunity to process microtasks between them.

Looking at "why" slightly differently: Why are the handlers called synchronously when you use click()? Primarily because of history, that's what early browsers did and so it can't be changed. But they're also synchronous if you use dispatchEvent:

const e = new MouseEvent("click");
btn.dispatchEvent(e);

In that case, the handlers are still run synchronously, because the code using it might need to look at e to see if the default action was prevented or similar. (It could have been defined differently, providing a callback or some such for when the event was done being dispatched, but it wasn't. I'd guess that it wasn't for either simplicity, compatibility with click, or both.)


dispatchEvent

Unlike "native" events, which are fired by the DOM and invoke event handlers asynchronously via the event loop, dispatchEvent invokes event handlers synchronously. All applicable event handlers will execute and return before the code continues on after the call to dispatchEvent.

dispatchEvent is the last step of the create-init-dispatch process, which is used for dispatching events into the implementation's event model. The event can be created using Event constructor.


So, Chrome answer just because it's interesting (see T.J Crowder's excellent answer for the general DOM answer).

btn.click();

Calls into HTMLElement::click() in C++ which is the counterpart of the DOMElement:

void HTMLElement::click() {
  DispatchSimulatedClick(nullptr, kSendNoEvents,
                         SimulatedClickCreationScope::kFromScript);
}

Which basically does some work around dispatchMouseEvent and deals with edge cases:

void EventDispatcher::DispatchSimulatedClick(
    Node& node,
    Event* underlying_event,
    SimulatedClickMouseEventOptions mouse_event_options,
    SimulatedClickCreationScope creation_scope) {
  // This persistent vector doesn't cause leaks, because added Nodes are removed
  // before dispatchSimulatedClick() returns. This vector is here just to
  // prevent the code from running into an infinite recursion of
  // dispatchSimulatedClick().
  DEFINE_STATIC_LOCAL(Persistent<HeapHashSet<Member<Node>>>,
                      nodes_dispatching_simulated_clicks,
                      (MakeGarbageCollected<HeapHashSet<Member<Node>>>()));

  if (IsDisabledFormControl(&node))
    return;

  if (nodes_dispatching_simulated_clicks->Contains(&node))
    return;

  nodes_dispatching_simulated_clicks->insert(&node);

  if (mouse_event_options == kSendMouseOverUpDownEvents)
    EventDispatcher(node, *MouseEvent::Create(event_type_names::kMouseover,
                                              node.GetDocument().domWindow(),
                                              underlying_event, creation_scope))
        .Dispatch();

  if (mouse_event_options != kSendNoEvents) {
    EventDispatcher(node, *MouseEvent::Create(event_type_names::kMousedown,
                                              node.GetDocument().domWindow(),
                                              underlying_event, creation_scope))
        .Dispatch();
    node.SetActive(true);
    EventDispatcher(node, *MouseEvent::Create(event_type_names::kMouseup,
                                              node.GetDocument().domWindow(),
                                              underlying_event, creation_scope))
        .Dispatch();
  }
  // Some elements (e.g. the color picker) may set active state to true before
  // calling this method and expect the state to be reset during the call.
  node.SetActive(false);

  // always send click
  EventDispatcher(node, *MouseEvent::Create(event_type_names::kClick,
                                            node.GetDocument().domWindow(),
                                            underlying_event, creation_scope))
      .Dispatch();

  nodes_dispatching_simulated_clicks->erase(&node);
}

It is entirely synchronous by design to make testing simple as well as for legacy reasons (think DOMActivate weird things).

This is just a direct call, there is no task scheduling involved. EventTarget in general is a synchronous interface that does not defer things and it predates microtick semantics and promises :]

Tags:

Javascript