-2

In this talk at 32:50 the speaker shows this code:

const nextClick = new Promise(resolve => {
    link.addEventListener('click', resolve, { once: true });
});

nextClick.then(event => {
    event.preventDefault();
    // Handle event
});

He explains why it prevents the click event's default action when it's caused directly by user interaction, but not when the dispatch is programmatic, e.g. link.click(). Is there a simple way to make it work in the latter case the same as in the former?

Edit to elaborate on motivation

@Kaiido asked in the comments why I (or rather the speaker) use a Promise here – I assume he'd like to suggest attaching directly as an event listener the function which calls preventDefault. The promise is convenient if I want to combine it with other promises using Promise.all or some other combinator.

ByteEater
  • 885
  • 4
  • 13
  • `event.preventDefault();` is always called but it doesn't prevent the default action if the event is dispatched programatically. The linked video explains why. – ByteEater Mar 14 '21 at 06:17
  • The `once` is irrelevant. The difference is that when the user clicks the `link`, the default action for the `click` event is prevented, but when `link.click()` is executed, it's not. – ByteEater Mar 14 '21 at 06:28
  • The comments above are for a guy who apparently deleted his 3 comments and his answer along with my comment on it. – ByteEater Mar 14 '21 at 08:04
  • The video seems to explain everything, what don't you understand there? Why do you have to use a Promise? – Kaiido Mar 14 '21 at 09:28
  • @Kaiido, the promise is convenient if I want to combine it with other promises using Promise.all or something. And the video just states the problem, explains the behaviour, but offers no solution. – ByteEater Mar 14 '21 at 17:23
  • For potentially more clarity, I'll add my reply to @adarsh-mohan's deleted answer, with his code edited by me to show why I downvoted the answer (and I suppose the downvote on the question is from him): – ByteEater Mar 14 '21 at 18:24
  • There's no default action for `click` on `button` elements. Try with the following code which has `a` instead. Then remove the line `link.click();`, reopen the document and click the link with your mouse to see that navigation doesn't happen. ``` link ``` – ByteEater Mar 14 '21 at 18:24
  • But the video clearly explains why **you can't** cancel a synthetic event from a Promise reaction. So either you didn't watch the video and want us to transcribe it to you, not cool. Either you already understand that you have to choose between the convenience of being able to use a Promise or being able to cancel the event. – Kaiido Mar 14 '21 at 23:34
  • I'm truly astonished by your interpreting my words this way. Perhaps, not being a native speaker of English, I unintentionally gave the wrong impression. The video explains the difference between the effects of `preventDefault` between interactive and programmatic dispatches of the event. Unless I misunderstand some subtlety, I can't find any moment when the speaker claims it's impossible in general. – ByteEater Mar 15 '21 at 01:03
  • So it's you, @Kaiido, saying that no other code, using any existing features (postMessage, Web Workers, whatever), would give me the full power of `preventDefault` and the convenience of being able to use a `Promise`, are you? If so, please do it in an answer, it would indeed resolve my question in the negative (no "simple way", because no way at all). – ByteEater Mar 15 '21 at 01:03
  • Rewatch the video, Jake's explains perfectly why with a synthetic event, the microtask checkpoint happens after the propagation is all done, and thus we "missed the boat". If a microtask later is too late, there is no other way than to handle this event synchronously. Really I don't think I can do a better job at explaining this than Jake here. – Kaiido Mar 15 '21 at 01:14
  • We seem to be talking past each other, unfortunately. I do understand what he explains (and you keep suggesting I don't). I'm just looking for an alternative which would have both of the considered advantages. He doesn't say there's no such alternative, whatever other features you use, he just explains why there's a non-obvious difference between the 2 specific pieces of code. If it's impossible in general, I'd consider it a valid answer. (Maybe I should have phrased my question differently, starting with the motivation, and only then mentioning the video as an example of the problem.) – ByteEater Mar 15 '21 at 01:26
  • And if, in addition to it being impossible, there is a known reason (e.g. some invariant, guarantee, whatever you call it, that is supposed to be relied on) why it shouldn't be possible (therefore no future API enabling it should be expected either), that would make an excellent answer in the negative (assuming it actually is negative). – ByteEater Mar 15 '21 at 01:35

1 Answers1

-1

The video explains perfectly what happens, but it seems we have to paraphrase it anyway...

In the case of a "native" event, the browser will "queue a task" to fire an event, which will get dispatched to your target and invoke all the listeners one after another, and finally call their JS callback. Since the JS stack is empty between each callback execution, a microtask-checkpoint is performed, and the Promises that got resolved during these callbacks get their callbacks executed.
Only after all these listeners have been invoked, the default action will get executed, if the event's cancelled flag hasn't been raised.

const link = document.querySelector("a");
link.addEventListener("click", (evt) => {
  console.log("event 1:", evt.defaultPrevented);
  Promise.resolve().then( () => {
    console.log("microtask 1:", evt.defaultPrevented); // false
    evt.preventDefault();
  });
});
link.addEventListener("click", (evt) => {
  console.log("event 2:", evt.defaultPrevented); // true
  Promise.resolve().then( () => {
    console.log("microtask 2:", evt.defaultPrevented); // true
  });
    
});
#target {
  margin-top: 600vh;
}
<a href="#target">go to target</a>
<div id="target">target</div>

However, in the case of a synthetic event fired by JS, that event is dispatched synchronously and the JS stack is never empty (since it at least contains the job that did fire the event in the first place).
So when the browser has to perform the "clean up after running script" algorithm after it called each of our JS callbacks, it will not perform a microtask-checkpoint this time.
Instead, it will continue until the step 12 of the dispatch an event algorithm with the cancelled flag still down, and will execute the default action. Only after this, it will return from whatever did fire that synthetic event, and only after that script will get cleaned after the browser will be able to execute the microtask-checkpoint.

In the following snippet I'll use an <input type="checkbox"> element because its activation behavior is synchronous, whereas <a>'s "navigate a link" isn't and thus doesn't make for a good example.

const input = document.querySelector("input");
input.addEventListener("click", (evt) => {
  console.log("event fired");
  Promise.resolve().then( () => {
    console.log("microtask fired, preventing");
    evt.preventDefault();
  });
});

console.log("before click, is checkbox checked:", input.checked);
input.click();
console.log("after click, is checkbox checked:", input.checked);
#target {
  margin-top: 600vh;
}
<input type="checkbox">
<div id="target">target</div>

So now we just said what Jake said in his presentation.

Let's make it a bit more focused on OP's case who want to still be able to deal with their event handler as a Promise, and want to be able to prevent the default behavior of the event.

This is not possible.

For an object to work with Promises as a Promise its callback has to get executed in a microtask-checkpoint. As we've seen above, the microtask-checkpoint will only get performed after the default behavior has been executed. So this is a dead-end.

What could be done:

  • prevent the default behavior in the event handler rather than in the Promise reaction:

    const link = document.querySelector("a");
    const prom = new Promise( (resolve)  => {
      link.addEventListener("click", (evt) => {
        evt.preventDefault();
        resolve(evt);
      }, { once: true } );
    });
    
    prom.then( (evt) => console.log("clicked") ); // true
    
    link.click();
    #target {
      margin-top: 600vh;
    }
    <a href="#target">go to target</a>
    <div id="target">target</div>
    But this means that either you prevent all default behaviors, either you move some logic inside the even handler, maybe defeating the idea of having a Promise there in the first place.
    Still, if you just want something to be chained after this event, this might be a viable solution.
  • Don't use a real Promise but just something with a similar API, that will execute the callback synchronously:

    class EventReaction {
      constructor( executor ) {
        this._callbacks = [];
        executor( this._resolver.bind( this ) );
      }
      after( callback ) {
        if( this._done ) {
          callback();
        }
        else {
          this._callbacks.push( callback );
        }
      }
      _resolver( arg ) {
        this._callbacks.forEach( cb => cb( arg ) );
        this._callbacks.length = 0;
      }
    }
    
    const link = document.querySelector("a");
    const event_reaction = new EventReaction( (resolve)  => {
      link.addEventListener("click", (evt) => {
        resolve(evt);
      }, { once: true } );
    } );
    
    event_reaction.after( (evt) => {
      console.log("preventing");
      evt.preventDefault();  
    });
    
    link.click();
    #target {
      margin-top: 600vh;
    }
    <a href="#target">go to target</a>
    <div id="target">target</div>
    But this is not a Promise, and can't be chained with a Promise nor used by any of the Promise's methods.

Now, the call is yours.

Kaiido
  • 123,334
  • 13
  • 219
  • 285