Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

Tags:

There's a difference in the execution order of the microtask/task queues when a button is clicked in the DOM, vs it being programatically clicked.

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');
});
<button id='btn'>Click me !</button>

My understanding is that when the callstack is empty, the event loop will take callbacks from the microtask queue to place on the callstack. When both the callstack and microtask queue are empty, the event loop starts taking callbacks from the task queue.

When the button with the id btn is clicked, both "click" event listeners are placed on the task queue in order they are declared in.

// representing the callstack and task queues as arrays
callstack: []
microtask queue: []
task queue: ["click-1", "click-2"]

The event loop places the "click-1" callback on the callstack. It has a promise that immediately resolves, placing the "resolved-1" callback on the microtask queue.

callstack: ["click-1"]
microtask queue: ["resolved-1"]
task queue: ["click-2"]

The "click-1" callback executes its console.log, and completes. Now there's something on the microtask queue, so the event loop takes the "resolved-1" callback and places it on the callstack.

callstack: ["resolved-1"]
microtask queue: []
task queue: ["click-2"]

"resolved-1" callback is executed. Now both the callstack and microtask queue and are empty.

callstack: []
microtask queue: []
task queue: ["click-2"]

The event loop then "looks" at the task queue once again, and the cycle repeats.

// "click-2" is placed on the callstack
callstack: ["click-2"]
microtask queue: []
task queue: []

// Immediately resolved promise puts "resolved-2" in the microtask queue
callstack: ["click-2"]
microtask queue: ["resolved-2"]
task queue: []

// "click-2" completes ...
callstack: []
microtask queue: ["resolved-2"]
task queue: []

// "resolved-2" executes ...
callstack: ["resolved-2"]
microtask queue: []
task queue: []

// and completes
callstack: []
microtask queue: []
task queue: []

This would explain this output from the code snippet above

"hello click1"
"resolved click1"
"hello click2"
"resolved click2"

I would expect it to be the same then I programatically click the button with btn.click().

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');
});

btn.click()
<button id='btn'>Click me!</button>

However, the output is different.

"hello click1"
"hello click2"
"resolved click1"
"resolved click2"

Why is there a difference in the execution order when button is programatically clicked ?

like image 869
peonicles Avatar asked Apr 16 '19 13:04

peonicles


People also ask

What is a difference between task and microtask?

A macro task represents some discrete and independent work. Microtasks, are smaller tasks that update the application state and should be executed before the browser continues with other assignments such as re-rendering the UI. Microtasks include promise callbacks and DOM mutation changes.

What is the difference between microtask and Macrotask?

The micro-task function itself takes no parameters, and does not return a value. Macro-tasks within an event loop: Macro-task represents some discrete and independent work. These are always the execution of the JavaScript code and micro-task queue is empty.

What are Microtasks What is a microtask queue What is their role in promises and how are they different from callbacks?

Microtask Queue: Microtask Queue is like the Callback Queue, but Microtask Queue has higher priority. All the callback functions coming through Promises and Mutation Observer will go inside the Microtask Queue. For example, in the case of . fetch(), the callback function gets to the Microtask Queue.

What is microtask in JavaScript?

A microtask is a short function which is executed after the function or program which created it exits and only if the JavaScript execution stack is empty, but before returning control to the event loop being used by the user agent to drive the script's execution environment.


2 Answers

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.)

like image 191
T.J. Crowder Avatar answered Sep 17 '22 18:09

T.J. Crowder


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 :]

like image 31
Benjamin Gruenbaum Avatar answered Sep 19 '22 18:09

Benjamin Gruenbaum