Rick Strahl's Weblog  

Wind, waves, code and everything in between...
.NET • C# • Markdown • WPF • All Things Web
Contact   •   Articles   •   Products   •   Support   •   Advertise
Sponsored by:
Markdown Monster - The Markdown Editor for Windows

Async Event Methods and preventDefault() in JavaScript


:P
On this page:

Async and Await make asynchronous operations in JavaScript a lot easier, but as easy at it seems at times you can get into trouble with async and await as it can subtly change behavior from similar synchronous code. Subtly being the operative word: You might think it's working the same, when really it is not! At the end of the day one still has to have an understanding on what happens under the hood with async\await.

From Sync to Async

I ran into an issue recently where I was trying to intercept document link clicks and route them to an external browser window in the Windows host application Markdown Monster. The code recently had to move from sync to async as the WebView callbacks into .NET are required to be asynchronous.

Specifically I was using code like the following synchronously:

document.addEventListener("click", function (e) {
    if (e.target.nodeName != "A" || !te.mmEditor) return;

    const el = e.target;
    const url = el.href;                // fixed up url
    const rawHref = el.attributes["href"].value;
    if (!rawHref) return false;

    // call into .NET - get true or false if navigation was completed (sync)
    const handled = te.dotnetInterop.previewLinkNavigation(url, rawHref);

    // handled: don't navigate the browser
    if(handled) {
        e.preventDefault();
        return false;
    }

    // If we opened a document with a hash
    // navigate to the hash location in the new document
    if (el.hash) {
        if (!navigateHash(el.hash)) {
            e.preventDefault()
            return false;
        }
    };
    
    return; // default behavior: navigate the browser
});  

This code calls into an external component that returns true or false in this line:

const handled = te.dotnetInterop.previewLinkNavigation(url, rawHref);

Then based on the result, if navigation was handled by the external application (Markdown Monster) I want to stop the browser's native navigation:

// if externally handled we're done here!
if(handled) {
    e.preventDefault();
    return false;
}

That all worked fine for years, when the code was synchronous.

Moving to Async - Breaking the Logic

A while back I had to switch to async APIs due to changes in the host WebView application's interface requirements which required that the call be made asynchronously firing into a async Task<bool> in .NET. This was required to allow the .NET code to properly pass forward an async context - the old synchronous call was randomly locking up the asynchronous UI operations.

Async and Await is easy, so we'll just change the code to this, right?

document.addEventListener("click", async function (e) {
    if (e.target.nodeName != "A" || !te.mmEditor) return;

    const el = e.target;
    const url = el.href;                // fixed up url
    const rawHref = el.attributes["href"].value;
    if (!rawHref) return false;

     const handled = await te.dotnetInterop.previewLinkNavigationAsync(url, rawHref);

    // if externally handled don't navigate this window
    if(handled) {
        e.preventDefault();
        return false;
    }

    // If we opened a document with a hash
    // navigate to the hash location in the new document
    if (el.hash) {
        if (!navigateHash(el.hash)) {
            e.preventDefault();
            return false;
        }
    };
    
    return;  // default behavior: navigate window
});  

Notice that the changes for async are ridiculously minimal:

  • async function(e) for function header
  • await to call the async interop method

Isn't async simple? 😄

And that seemed to work at first.

I didn't notice it right away, but navigation now fired into the host application to do the external navigation (good), but... the internal browser also navigated to the linked page (bad).

The code is supposed to not navigate because of the conditional e.preventDefault() block and return false. That code is executed, but e.preventDefault() has no effect.

What's going on here?

Async Behavior of preventDefault()

Async is not the same as sync and in this scenario, it's one of those instances where it can bite you. Specifically the issue is this:

If you call e.preventDefault() and return false after a call to await, e.preventDefault() has no effect.

In hindsight that makes sense:

  • the await call executes out of band
  • the event completes before the first await call

What this means is that click event completes before the first await call. Anything you do to event after the await is called is ignored!

In my code e.preventDefault() and return false have no effect, because it fires after the await call and the event is already done and the href click has already navigated the document. The code after the await still executes, but it is now effectively executing out-of-band outside of the original event context and thus has no effect.

In simple terms: The click event is not waiting for the await call to complete before completing.

The end result is: Both my .NET code and the browser navigate which is exactly what this code was trying to prevent in the first place.

Not what I want!

Work around Async Event State

In hindsight this is fairly obvious. The await introduces a wait state and context switch, so the event completes before the await. The event related code post await still runs and doesn't fail, but also has no effect.

Makes sense once you know the behavior, but especially if you're converting code from sync, this is often anything but obvious.

Knowing the behavior now, the key to make this work is to take over the event interaction directly, by handling the link navigation manually rather than relying on the event to trigger the navigation. So instead of using preventDefault() conditionally, I can always disable navigation by calling preventDefault() before the await where it has an actual effect on the event. Then in the code following the await I can check the result value and if necessary, manually navigate the document or not all depending on my application logic.

For <a href> links this is easy to do, for other UI events this may be trickier.

The key is to fire e.preventDefault() before the await call, so it is applied to the event before it completes. In some cases you can move the logic prior to the await which would be optimal. In my case I had to use the await to decide on whether the event aborts, so I had to take over the entire event handling using my own code - manual navigation in this case.

Here's what this looks like for the above code:

document.addEventListener("click", async function (e) {
    if (e.target.nodeName != "A" || !te.mmEditor) return;

    const el = e.target;
    const url = el.href;                // fixed up url
    const rawHref = el.attributes["href"].value;
    if (!rawHref) return false;

    // prevent ALL clicks from navigating
    e.preventDefault();

    // pure hash navigation  - have to do this here now
    if (rawHref[0] == "#") {
        navigateHash(el.hash);
        return false;
    }
    
    const handled = await te.dotnetInterop.previewLinkNavigationAsync(url, rawHref);

    // document hash navigation if we opened MD document
    if (el.hash) {
        if (!navigateHash(el.hash))
            return;   // no browser nav
    }

    // navigate manually if not handled
    if (!handled)
       window.location.href = url;
    
    return;  //  no browser nav
});

The preventDefault() call prevents the browser from navigating, even though I may still need it to later. If the result comes back as handled there's nothing else to do but exit.

If the result is not handled I then manually navigate the browser to the new location.

Summary

At the end of the day async code is not sync code even if async and await sometimes can lull you into thinking that it is. Behind the scenes the code is still asynchronous and it will change the way the code executes. And that can have side effects.

In the example the side effect is that the event object's state was already applied by the time the await call returns and any further state changes on the event properties/methods are ignored. If you need to access that behavior after the fact, you may have to find another way to perform the default event behavior conditionally.

Now I know and you do too - and I'll likely make this mistake again, regardless 😄 But hopefully I find this solution a little quicker next time around.

this post created and published with the Markdown Monster Editor
Posted in JavaScript  HTML  

The Voices of Reason


 

Peter
February 17, 2023

# re: Async Event Methods and preventDefault() in JavaScript

The same would also apply to returning "false" from the listener function after an await. However, you should remove those "return false" statements altogether, because that's simply the legacy way of "preventDefault" and actually not formally specified. Event listeners should return nothing. See: https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#the_event_listener_callback


Richard Clarke
February 17, 2023

# re: Async Event Methods and preventDefault() in JavaScript

I'm glad to see you blogging more again Rik.

Your content is always super useful


Parker Too
April 07, 2024

# re: Async Event Methods and preventDefault() in JavaScript

Ha - I have just spent hours on exactly this problem, then came across your post. Thanks for posting - I thought I was going mad!


West Wind  © Rick Strahl, West Wind Technologies, 2005 - 2024