Search code examples
javascriptgoogle-chromefirefoxerror-handlinges6-promise

Handling errors in async event handlers in JavaScript in the web browser


This is just another hopeless try to handle errors in async event handlers.

A note about this example: The example here works differently than it does if it is run directly in the browser. If ran directly in the browser none of the event listeners for errors is working ("error", "unhandledrejection").

It looks similar on Windows 10 in Chrome (Version 80.0.3987.163 (Official Build) (64-bit)) and Firefox (75.0 (64-bit)).

The only way I have found to handle this is to never make any typos. But that does not work either for me.

How is this supposed to work?

window.addEventListener("error", evt => {
    console.warn("error event handler", evt);
    output("error handler: " + evt.message, "yellow");
});
window.addEventListener("unhandledrejection", evt => {
    console.warn("rejection event handler", evt);
    output("rejection handler: " + evt.message, "green");
});
function output(txt, color) {
    const div = document.createElement("p");
    div.textContent = txt;
    if (color) div.style.backgroundColor = color;
    document.body.appendChild(div);
}

const btn = document.createElement("button");
btn.innerHTML = "The button";
btn.addEventListener("click", async evt => {
    evt.stopPropagation();
        output("The button was clicked");
        noFunction(); // FIXME: 
})
document.body.appendChild(btn);

const btn2 = document.createElement("button");
btn2.innerHTML = "With try/catch";
btn2.addEventListener("click", async evt => {
    evt.stopPropagation();
    try {
        output("Button 2 was clicked");
        noFunction2(); // FIXME: 
    } catch (err) {
        console.warn("catch", err)
        throw Error(err);
    }
})
document.body.appendChild(btn2);

new Promise(function(resolve, reject) {
    setTimeout(function() {
        return reject('oh noes');
    }, 100);
});

justAnError();
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<script defer src="error-test.js"></script>


EDIT - adding output from Chrome and JS Bin (Link to JS Bin example)

Loading page

Chrome/Firefox:

error handler: Script error.

JS Bin:

error handler: Uncaught ReferenceError: justAnError is not defined

rejection handler: undefined

Clicking left button

Chrome/Firefox:

The button was clicked

JS Bin:

The button was clicked

rejection handler: undefined


Solution

  • You could give yourself utility functions for error reporting and wrapping event handlers, like this:

    function handleError(err) {
        if (!(err instanceof Error)) {
            err = Error(err);
        }
        output("error handler: " + err.message, "yellow");
    }
    
    function wrapHandler(fn) {
        return function(evt) {
            new Promise(resolve => {
                resolve(fn(evt));
            }).catch(e => {
                handleError(e);
            });
        };
    }
    

    That supports both async and non-async event handlers. If there's a synchronous error calling fn, it's caught by the promise constructor and turned into a rejection of the promise being created. If there isn't, the promise is resolved to the return value of the fn, meaning that if fn returns a promise that rejects, the promise created by new Promise is rejected. So either way, errors go to the error handler.

    I haven't tried to distinguish between errors and rejections, as they're fundamentally the same thing, but you could if you want:

    function handleError(err, isRejection) {
        if (!(err instanceof Error)) {
            err = Error(err);
        }
        output("error handler: " + err.message, isRejection ? "green" : "yellow");
    }
    
    function wrapHandler(fn) {
        return function(evt) {
            try {
                const result = fn(event);
                Promise.resolve(result).catch(e => handleError(e, true));
            } catch (e) {
                handleError(e, false);
            }
        };
    }
    

    Either way, you'd set up your global handlers to use it and prevent the default:

    window.addEventListener("error", errorEvent => {
        handleError(errorEvent.error, false); // Remove the `, false` if you're not trying to make a distinction
        errorEvent.preventDefault();
    });
    
    window.addEventListener("unhandledrejection", errorEvent => {
        handleError(errorEvent.reason, true); // Remove the `, true` if you're not trying to make a distinction
        errorEvent.preventDefault();
    });
    

    You'd use wrapHandler when setting up your handlers, either directly:

    btn.addEventListener("click", wrapHandler(async evt => {
        evt.stopPropagation();
        output("The button was clicked");
        noFunction(); // FIXME: 
    }));
    

    ...or by having another utility function:

    function addListener(elm, eventName, fn) {
        const handler = wrapHandler(fn);
        return elm.addEventListener(eventName, handler);
        return function() {
            elm.removeEventListener(handler);
        };
    }
    

    ...then:

    const removeBtnClick = addListener(btn, "click", async evt => {
        evt.stopPropagation();
        output("The button was clicked");
        noFunction(); // FIXME: 
    });
    // ...if you want to remove it later...
    removeBtnClick();
    

    Live Example — since your original distinguished between synchronous errors and rejections, I've used that variant here, but again, its' really a distinction without a difference and I wouldn't distinguish them in my own code:

    function handleError(err, isRejection) {
        if (!(err instanceof Error)) {
            err = Error(err);
        }
        output("error handler: " + err.message, isRejection ? "green" : "yellow");
    }
    
    window.addEventListener("error", errorEvent => {
        handleError(errorEvent.error, false);
        errorEvent.preventDefault();
    });
    
    window.addEventListener("unhandledrejection", errorEvent => {
        handleError(errorEvent.reason, true);
        errorEvent.preventDefault();
    });
    
    function wrapHandler(fn) {
        return function(evt) {
            try {
                const result = fn(event);
                Promise.resolve(result).catch(e => handleError(e, true));
            } catch (e) {
                handleError(e, false);
            }
        };
    }
    
    function addListener(elm, eventName, fn) {
        const handler = wrapHandler(fn);
        return elm.addEventListener(eventName, handler);
        return function() {
            elm.removeEventListener(handler);
        };
    }
    
    function output(txt, color) {
        const div = document.createElement("p");
        div.textContent = txt;
        if (color) div.style.backgroundColor = color;
        document.body.appendChild(div);
    }
    
    const btn = document.createElement("button");
    btn.innerHTML = "The button";
    addListener(btn, "click", async evt => {
        evt.stopPropagation();
        output("The button was clicked");
        noFunction(); // FIXME: 
    });
    document.body.appendChild(btn);
    
    const btn2 = document.createElement("button");
    btn2.innerHTML = "With try/catch";
    addListener(btn2, "click", async evt => {
        evt.stopPropagation();
        try {
            output("Button 2 was clicked");
            noFunction2(); // FIXME: 
        } catch (err) {
            console.warn("catch", err)
            throw Error(err);
        }
    });
    document.body.appendChild(btn2);
    
    new Promise(function(resolve, reject) {
        setTimeout(function() {
            return reject('oh noes');
        }, 100);
    });
    
    justAnError();
    <meta charset="utf-8">
    <meta name="viewport" content="initial-scale=1">