Search code examples
javascriptjquerycookiesjquery-deferredrevealing-module-pattern

Returning a jQuery Promise in Revealing Module Pattern


I am writing a custom library in the Revealing Module pattern to handle a specific cookie and trying to use a jQuery Promise as a return for the cookie's "Getter" function in order to keep those calling the function from updating the function before it is initially set, thus keeping it synchronous.

See below:

/**
 * Handles the state cookie for a browser.
 *
 * JS DEPENDENCIES:
 * - jQuery Cookie plugin
 *
 * DOM DEPENDENCIES:
 * - None
 *
 */
var myUtilities = myUtilities || {};

myUtilities.stateManager = (function() {
    var cookieName = 'us_state';

    /**
     * Find geolocation state / set cookie
     * The passed deferred object only gets set as resolved if the AJAX response has the resulting data we need. Otherwise it is rejected.
     *
     * @param  {Object} position Passed from 'navigator.geolocation.getCurrentPosition'. Contains browser's approximation of its current latitude+longitude.
     * @return {Object}          The promise resolution (resolve or reject). Resolved has a String of state abbreviation in lowecase. Rejected is empty.
     */
    function _getLocation(position) {
        var latitude  = position.coords.latitude,
            longitude = position.coords.longitude;

        /* TEST VALUES */
        /* CA coords */
        // latitude  = '37.7833';
        // longitude = '-122.4167';
        /* AZ coords */
        // latitude  = '33.45';
        // longitude = '-112.0667';

        // If this errors out due to CORS issue (or similar issue) of if the return value doesn't match then we set the promise to reject
        return $.ajax({
            url: 'https://maps.googleapis.com/maps/api/geocode/json?latlng=' + latitude + ',' + longitude,
            dataType: "json"
        });
    }

    /**
     * Defer for getCurrentPosition callback
     * Create an anonymous function to handle success; accepts a Position object as argument, and calls _getLocation() passing in the position object.
     * When AJAX promise is complete evalute the data to find the state abbreviation.
     * Reject a failed call for getCurrentPosition (user did not allow/timeout on browser's request to use geolocation)
     *
     * @var {Object} $df jQuery Deferred object
     * @return {Object} jQuery Promise
     */
    function _deferGetLocation() {
        var $df = $.Deferred();

        if ("geolocation" in navigator) {
            navigator.geolocation.getCurrentPosition(
                function(position) {
                    _getLocation(position)
                        .then(function(data) {
                            if (data.length !== 0) {
                                var result  = data.results[0],
                                    address = '',
                                    state   = '';

                                // A for-loop is used because the response changes based on the address that Google API returns (a single search into a specific part of the data Object is not always successful evne though the data may be in there)
                                for (var i = 0, len = result.address_components.length; i < len; i++) {
                                    address = result.address_components[i];

                                    if (address.types.indexOf('administrative_area_level_1') >= 0) {
                                        // By returning here we exit the loop as soon as we get a match, like a 'break'
                                        $df.resolve(address.short_name.toLowerCase());
                                        break;
                                    }
                                }
                            }
                        });
                    });
        } else {
            $df.reject();
        }

        return $df.promise();
    }

    /**
     * Either get the get cookie or set it now.
     * If the cookie exists we resolve the promise immediately, else wait for the geolocation to be resolved, set state cookie and resolve.
     *
     * @var {Object} $df         jQuery Deferred object
     * @var {String} stateString state, 2 character abbreviation format
     * @return {Object} Promise with a String for the callback (two-character value indicating which state the user is in)
     */
    function _getStateCookie(){
        var $df = $.Deferred();

        if ($.cookie(cookieName)) {
            $df.resolve($.cookie(cookieName));
        } else {
            _deferGetLocation()
                .then(function(state) {
                    $df.resolve(_setStateCookie(state));
                });
        }

        return $df.promise();
    }

    /**
     * Set the 'cookieName' cookie to a desired state, or default to 'co'
     *
     * @param {String} state The value of the cookie as a 2 character length state abbreviation
     * @param {Datetime} expirationDate Days until the cookie expires
     */
    function _setStateCookie (state, expirationDate){
        state          = ( typeof state == 'undefined' || !_isValidState(state) ) ? 'co' : state;
        expirationDate = ( typeof expirationDate == 'undefined' ) ? 365 : expirationDate;

        $.cookie(cookieName, state, { path: '/', expires: expirationDate });

        // Offer an event listener for this cookie
        $(document).trigger('state-utility.cookieChange');

        return state;
    }

    /**
     * Validates a given string against our predetermined "valid states" (AZ, CA, CA).
     * Returns  true if valid, false otherwise.
     * Case-sensitive, AZ == az -> false
     *
     * @param  {String}  state A value to be compared for valid state
     * @return {Boolean}       True if valid, false otherwise
     */
    function _isValidState(state) {
        return (state == 'az' || state == 'ca' || state == 'ca');
    }

    function _isCookieSet() {
        return ($.cookie(cookieName) && _isValidState($.cookie(cookieName)));
    }

    return {
        // Using a Promise so that multiple calls to _getStateCookie() are handled synchronously
        getStateCookie : function() {
            return _getStateCookie().then( function(state) { return state; });
        },
        setStateCookie : function(state, expirationDate) {
            return _setStateCookie(state, expirationDate);
        },
        updateStateElement : function(target) {
            return _updateStateElement(target);
        },
        isValidState : function(state) {
            return _isValidState(state);
        },
        isCookieSet : function() {
            return _isCookieSet();
        }
    };
})();
<script src="https://raw.githubusercontent.com/carhartl/jquery-cookie/master/src/jquery.cookie.js"></script>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>

When the problem arises in trying to retrieve the cookie's value using myUtilities.stateManager.getStateCookie(). I expect this call to return with a two-character string for the nearest applicable state. Instead I get the Promise object returned.

Why does the Promise get returned instead of the string, and what needs to be changed in order to return the desired string?

Thank you for your time.


Solution

  • I'm afraid that you can't expect ever to derive a synchronous result from an asynchronous process in javascript. Nothing you do will ever convert async to sync. The best you can hope for (one day in the foreseeable future) is syntax that makes async code look more sync-like.

    Here are some suggestions ...

    In _getLocation(), I would :

    • add a fail handler to normalise jQuery.ajax's error reporting into a single reason.
    function _getLocation(position) {
        var latitude  = position.coords.latitude,
            longitude = position.coords.longitude;
    
        return $.ajax({
            url: 'https://maps.googleapis.com/maps/api/geocode/json?latlng=' + latitude + ',' + longitude,
            dataType: "json"
        }).then(null, function(jqXHR, textStatus, errorThrown) {
            return errorThrown;
        });
    }
    

    In _deferGetLocation(), I would :

    • purge the explicit promise construction antipattern from _deferGetLocation().
    • provide reasons for promise rejection under two consitions.
    • tidy slightly.
    function _deferGetLocation() {
        var promise;
        if ("geolocation" in navigator) {
            navigator.geolocation.getCurrentPosition(function(position) {
                promise = _getLocation(position).then(function(data) {
                    var result = data.results[0],
                        state;
                    if (data.length !== 0) {
                        // A for-loop is used because the response changes based on the address that Google API returns (a single search into a specific part of the data Object is not always successful even though the data may be in there)
                        for (var i = 0, len = result.address_components.length; i < len; i++) {
                            if (result.address_components[i].types.indexOf('administrative_area_level_1') >= 0) {
                                state = result.address_components[i].short_name.toLowerCase();
                                break;
                            }
                        }
                    }
                    return state || $.Deferred().reject('geolocation failed').promise();
                });
            });
        return promise || $.Deferred().reject('browser does not support geolocation').promise();
    }
    

    In the renamed _getStateCookie(), I would:

    • rename as _getStateCookieAsync() as a warning to consumers that the method returns a promise.
    • purge the explicit promise construction antipattern from _getStateCookie() and simplify (big time).
    function _getStateCookieAsync() {
        var state = $.cookie(cookieName);
        return (state) ? $.when(state) : _deferGetLocation().then(_setStateCookie);
    }
    

    And in the method-exposing return statement I would :

    • expose only what's necessary - there's no obligation to expose every method.
    • expose by function name - no need for additional function wrappers.
    return {
        getStateCookieAsync : _getStateCookieAsync,
        setStateCookie : _setStateCookie, // will it ever be set from outside?
        // updateStateElement : _updateStateElement, // doesn't exist
        isValidState : _isValidState, // probably only of use internally
        isCookieSet : _isCookieSet
    };