SQuan-esristaff

ECMAScript 7: Promises in AppStudio

Blog Post created by SQuan-esristaff Employee on Jun 5, 2019

1. Javascript Promise

 

 

AppStudio 3.3, Qt 5.12.1 and ECMAScript 7 (which includes ECMAScript 6) introduces native Javascript Promises.

 

Note that the ArcGIS.AppFramework.Promises module is being deprecated in favour for native Javascript Promises. We advise to discontinue using ArcGIS.AppFramework.Promises module and begin removing them from your existing AppStudio apps.

 

Promises is a Javascript coding pattern for working with asynchronous tasks. To understand why we want them, we will be deep diving into the following 4 scenarios:

 

Scenario 1: Download 3 pages, but don't care when they complete

 

 

Scenario 2: Download 3 pages, but only want to be notified when all are completed

 

 

Scenario 3: Download 3 pages, but only want to be notified when the first has completed

 

 

 

Scenario 4: Download 3 web pages sequentially

 

 

 

 

2. What are Promises?

 

 

Javascript Promises is a popular Javascript coding pattern for asynchronous tasks.

 

Presently, in AppStudio and QML, you will need to use signals and slots to work with asynchronous tasks. If your applications are clearly written and easy to maintain then you probably don't need to look into Promises right now.

 

However if your applications are complex with business logic spanning over a complex sequence of signals and slots then buying into Promises may be the key to reduce code complexity.

 

In my previous blog I talked about setTimeout for calling functions after a set time delay. The following code will execute console.log(31, 32, 33) after waiting 0-1000 milliseconds.

 

    setTimeout( (a, b, c) => console.log(a, b, c), randomDelay(), 31, 32, 33)

 

Let's evolve the above snippet to an AppStudio app demonstrating Promises. For example, one that simulates downloading content from the internet:

 

import QtQuick 2.12
import QtQuick.Controls 2.5
import ArcGIS.AppFramework 1.0

App {
    id: app

    width: 640 * AppFramework.displayScaleFactor
    height: 480 * AppFramework.displayScaleFactor

    Button {
        text: qsTr("testPromiseTimeout")
        onClicked: testPromiseTimeout()
    }

    function download(url) {
        return new Promise( (resolve, reject) => {
            if (Math.random() < 0.30)
                setTimeout( reject, randomDelay(), `Download failure ${url}` )
            else
                setTimeout( resolve, randomDelay(), `Download success ${url}` )
        } )
    }

    function randomDelay() {
        return Math.floor(1000 * Math.random())
    }

    function testPromiseTimeout() {

        download("https://community.esri.com/groups/appstudio")
        .then( (message) => {
            console.log(message)
        } ).catch( (error) => {
            console.log(error)
        } )

        download("https://appstudio.arcgis.com")
        .then( (message) => {
            console.log(message)
        } ).catch( (error) => {
            console.log(error)
        } )

        download("https://community.esri.com/groups/survey123")
        .then( (message) => {
            console.log(message)
        } ).catch( (error) => {
            console.log(error)
        } )

    }

    function setTimeout(func, interval, ...params) {
        return setTimeoutComponent.createObject(app, { func, interval, params })
    }

    function clearTimeout(timerObj) {
        timerObj.stop()
        timerObj.destroy()
    }

    Component {
        id: setTimeoutComponent
        Timer {
            property var func
            property var params
            running: true
            repeat: false
            onTriggered: {
                func(...params)
                destroy()
            }
        }
    }
}

 

 

Quite a few things are happening above. Let's break it down bit by bit.

 

The first thing I would like to draw your attention is the mock download function, i.e.

 

    function download(url) {
        return new Promise( (resolve, reject) => {
            if (Math.random() < 0.30)
                setTimeout( reject, randomDelay(), `Download failure ${url}` )
            else
                setTimeout( resolve, randomDelay(), `Download success ${url}` )
        } )
    }

 

This is written in the language of Promises. For the time being, don't worry if you don't understand the meaning of resolve and reject. All you need to observe is this download function will succeed 70% of the time with "Download success <url>" but fails 30% of the time with the error "Download failure <url>".

 

setTimeout() is being used to give this download simulation an asynchronous feel. The download task will not complete right away but we guarantee it will complete in the future. In this case, within 0-1000 milliseconds from now.

 

Now, I used the word "guarantee" deliberately here. That's what Promises are about. We are making a guarantee that this task will finish. If it finishes successfully we will let you know by calling resolve(message) otherwise we will let you know of the failure by calling reject(error).

 

You also note that the download(url) function returns a new Promise object. This object is for your to receive the notifications of success or failure.

 

 

The Promise object usage can be seen later inside testPromiseTimeout():

 

        download("https://community.esri.com/groups/appstudio")
        .then( (message) => {
            console.log(message)
        } ).catch( (error) => {
            console.log(error)
        } )

 

This says "when the download completes successfully I will call console.log(message) otherwise I will call console.log(error) to show the failure". Note that the download task starts the moment new Promise() is called but the completion will be some time in future, but don't worry, when that happens either .then or .catch will be called.

 

The full testPromiseTimeout() function creates 3 concurrent download tasks. The tasks are started at the same time, and, each will complete 0-1000 milliseconds from now. Each task is completely independent of the other, so, there is no need to wait for each other.

 

    function testPromiseTimeout() {

        download("https://community.esri.com/groups/appstudio")
        .then( (message) => {
            console.log(message)
        } ).catch( (error) => {
            console.log(error)
        } )

        download("https://appstudio.arcgis.com")
        .then( (message) => {
            console.log(message)
        } ).catch( (error) => {
            console.log(error)
        } )

        download("https://community.esri.com/groups/survey123")
        .then( (message) => {
            console.log(message)
        } ).catch( (error) => {
            console.log(error)
        } )

    }

 

 

Right now, I suspect that you may be thinking this is looking confusing, and, you may not be convinced that you really need it. I ask you to consider this: the testPromiseTimeout() function fully describes 3 download tasks and what to do when each of them finishes. It describes this all this in one place, i.e. in the one function. If you didn't use Promises, we would have to create numerous signals and slots and spread the implementation throughout the application.

 

In the subsequent sections, we are going to dive into some Promise scenarios. With each scenario, you'll see a coding pattern, which, I'm hoping you'll find to be very clear and useful in your application.

 

 

 

3. Promise.all

 

 

Promise.all is a special Promise that only succeeds when every sub Promise succeeds else it fails reporting the error from the first failing sub Promise.

 

 

The following starts 3 concurrent download tasks. When all downloads have completed successfully, you will receive an array of the success messages, otherwise, you will receive the error of the first failed download.

 

import QtQuick 2.12
import QtQuick.Controls 2.5
import ArcGIS.AppFramework 1.0

App {
    id: app

    width: 640 * AppFramework.displayScaleFactor
    height: 480 * AppFramework.displayScaleFactor

    Button {
        text: qsTr("test Promise.all()")
        onClicked: testPromiseAll()
    }

    function testPromiseAll() {
        Promise.all( [
            download("https://community.esri.com/groups/appstudio"),
            download("https://appstudio.arcgis.com"),
            download("https://community.esri.com/groups/survey123")
        ] ).then( (messages) => {
            messages.forEach( (message) => console.log(message) )
        } ).catch( (error) => {
            console.log(error)
        } )
    }

    function download(url, ...form) {
        return new Promise( (resolve, reject) => {
            downloadComponent.createObject( app, { resolve, reject, url, form } )
        } )
    }

    Component {
        id: downloadComponent

        NetworkRequest {
            property var resolve
            property var reject
            property var form
            onReadyStateChanged: {
                if (readyState !== 4) return
                if (status < 200 || status >= 299) {
                    reject( qsTr("Download failure %1: Status Code %2: %3").arg(url).arg(status).arg(statusText) )
                }
                if (errorCode !== 0)
                {
                    reject( qsTr("Download failure %1: Error Code %2: %3").arg(url).arg(error).arg(errorText) )
                    destroy()
                    return
                }
                resolve( qsTr("Download success %1: %2").arg(url).arg(responseText.match(/<title>(.*)<\/title>/)[1]) )
                destroy()
            }
            Component.onCompleted: send(...form)
        }
    }
}

 

In this example, I've replaced my mock download() function with a real one. This will download a web page and extract the title from the <title>...</title> html source. On successful completion, you will be notified of the title, otherwise, you will be notified of the error. I let you read download() and the corresponding downloadComponent on your own, but, trust me that it will find the title text on a web page.

 

The code I want you to focus on is testPromiseAll(), i.e.

 

    function testPromiseAll() {
        Promise.all( [
            download("https://community.esri.com/groups/appstudio"),
            download("https://appstudio.arcgis.com"),
            download("https://community.esri.com/groups/survey123")
        ] ).then( (messages) => {
            messages.forEach( (message) => console.log(message) )
        } ).catch( (error) => {
            console.log(error)
        } )
    }

 

 

The first thing you'll note is it is very short! What does it do? Well, there are 3 sub Promises each downloading a different web page. The 3 sub Promises is wrapped up by Promise.all(). What it is doing is it will start 3 concurrent web page downloads and it will wait on all 3 of them for you. If all 3 are successful, the responses of each download is collated together as an array of 3 strings, one for each message ordered in the same order that you listed the 3 Promises.

 

When the application completes, I expect you to see:

 

 

The testPromiseAll() function is very short and clear description of my download needs. This was possible because download() was written to return a Promise object.

 

 

4. Promise.race

 

 

Promise.race is a special Promise in that we are only interested in the first sub Promise that finishes the race. We aren't interested in the completion of the subsequent Promises. We will be notified the success message or failure error of that first sub Promise.

 

The following starts 3 concurrent download tasks. When the first download completes you will be notified of it's success or failure. The remaining download tasks will be ignored.

 

import QtQuick 2.12
import QtQuick.Controls 2.5
import ArcGIS.AppFramework 1.0

App {
    id: app

    width: 640 * AppFramework.displayScaleFactor
    height: 480 * AppFramework.displayScaleFactor

    Button {
        text: qsTr("test Promise.race()")
        onClicked: testPromiseRace()
    }

    function testPromiseRace() {
        Promise.race( [
            download("https://community.esri.com/groups/appstudio"),
            download("https://appstudio.arcgis.com"),
            download("https://community.esri.com/groups/survey123")
        ] ).then( (message) => {
            console.log(message)
        } ).catch( (error) => {
            console.log(error)
        } )
    }

    function download(url, ...form) {
        return new Promise( (resolve, reject) => {
            downloadComponent.createObject( app, { resolve, reject, url, form } )
        } )
    }

    Component {
        id: downloadComponent

        NetworkRequest {
            property var resolve
            property var reject
            property var form
            onReadyStateChanged: {
                if (readyState !== 4) return
                if (status < 200 || status >= 299) {
                    reject( qsTr("Download failure %1: Status Code %2: %3").arg(url).arg(status).arg(statusText) )
                }
                if (errorCode !== 0)
                {
                    reject( qsTr("Download failure %1: Error Code %2: %3").arg(url).arg(error).arg(errorText) )
                    destroy()
                    return
                }
                resolve( qsTr("Download success %1: %2").arg(url).arg(responseText.match(/<title>(.*)<\/title>/)[1]) )
                destroy()
            }
            Component.onCompleted: send(...form)
        }
    }
}

 

The key function is testPromiseRace() which starts the race between 3 downloads. Two are GeoNet community pages, and the other is the AppStudio home page. Which page will win the race? Anybody's guess?

 

    function testPromiseRace() {
        Promise.race( [
            download("https://community.esri.com/groups/appstudio"),
            download("https://appstudio.arcgis.com"),
            download("https://community.esri.com/groups/survey123")
        ] ).then( (message) => {
            console.log(message)
        } ).catch( (error) => {
            console.log(error)
        } )
    }

 

Surprisingly, no matter how many times you run this application, you find that the output to be always:

 

 

Why is the second Promise always succeeding the race? That's because the AppStudio Home Page loads much faster than the community GeoNet pages! i.e. the download("https://appstudio.arcgis.com") always wins the Promise.race()!

 

Use Promise.race() when you only want to be notified when the first Promise completes.

 

 

5. Promise chaining

 

 

Promise chaining is a Promise coding pattern whereby we wait for successful completion of one Promise before commencing the next. This sequential pattern is usually required when you need the result from one Promise before commencing the next.

 

Note that Promise chains aren't fully implemented in AppStudio 3.3 and Qt 5.12.1 due to a bug.

 

Please vote to have it fixed here: https://bugreports.qt.io/browse/QTBUG-71329

 

The following code demonstrates how one should use Promise chaining to download 3 web pages one at a time. See the testPromiseChaining() for the recommended pattern. Eventhough we're describing asynchronous tasks, the pattern is listed neatly in a single function that is relatively easy to maintain.

 

import QtQuick 2.12
import QtQuick.Controls 2.5
import QtQuick.Layouts 1.12
import ArcGIS.AppFramework 1.0

App {
    id: app

    width: 640 * AppFramework.displayScaleFactor
    height: 480 * AppFramework.displayScaleFactor

    Button {
        text: qsTr("test Promise chaining")
        onClicked: testPromiseChaining()
    }

    function testPromiseChaining() {

        // Promise chains aren't fully implemented https://bugreports.qt.io/browse/QTBUG-71329
        download("https://community.esri.com/groups/appstudio")
        .then( (data) => {
            console.log(data)
            return download("https://appstudio.arcgis.com")
        } )
        .catch( (error) => {
            console.log(error)
        } )
        .then( (data) => {
            console.log(data)
            return download("https://community.esri.com/groups/survey123")
        } )
        .catch( (error) => {
            console.log(error)
        } )
        .then( (data) => {
            console.log(data)
        } )
        .catch( (error) => {
            console.log(error)
        } )
    }

    function download(url, ...form) {
        return new Promise( (resolve, reject) => {
            downloadComponent.createObject( app, { resolve, reject, url, form } )
        } )
    }

    Component {
        id: downloadComponent

        NetworkRequest {
            property var resolve
            property var reject
            property var form
            onReadyStateChanged: {
                if (readyState !== 4) return
                if (status < 200 || status >= 299) {
                    reject( qsTr("Download failure %1: Status Code %2: %3").arg(url).arg(status).arg(statusText) )
                }
                if (errorCode !== 0)
                {
                    reject( qsTr("Download failure %1: Error Code %2: %3").arg(url).arg(error).arg(errorText) )
                    destroy()
                    return
                }
                resolve( qsTr("Download success %1: %2").arg(url).arg(responseText.match(/<title>(.*)<\/title>/)[1]) )
                destroy()
            }
            Component.onCompleted: send(...form)
        }
    }
}

 

Expected output is

 

 

However, due to the https://bugreports.qt.io/browse/QTBUG-71329 the actual output is

 

 

As a workaround you will need to use Promise nesting.

 

 

6. Promise nesting

 

 

Promise nesting is another technique to ensure asynchronous Promises are executed in sequential order.

 

The follow starts 3 download tasks in sequential order, and will stop either when they all complete successfully or when one of them has failed. Similar to the previous example on Promise chaining, the Promise nesting code is all in one function where you can read the entire workflow.

 

However, this implementation suffers from what Javascript developers name "callback ****" and requires another level of code indentation. It is what Promise chaining was designed to help fix.

 

import QtQuick 2.12
import QtQuick.Controls 2.5
import ArcGIS.AppFramework 1.0

App {
    id: app

    width: 640 * AppFramework.displayScaleFactor
    height: 480 * AppFramework.displayScaleFactor

    Button {
        text: qsTr("test Promise nesting")
        onClicked: testPromiseNesting()
    }

    function testPromiseNesting() {
        download("https://community.esri.com/groups/appstudio")
            .then( (message) => {
            console.log(message)
            download("https://appstudio.arcgis.com")
            .then( (message) => {
                console.log(message)
                download("https://community.esri.com/groups/survey123")
                .then( (message) => {
                    console.log(message)
                } )
                .catch( (error) => {
                    console.log(error)
                } )
            } )
            .catch( (error) => {
                console.log(error)
            } )
        } )
        .catch( (error) => {
            console.log(error)
        } )
    }

    function download(url, ...form) {
        return new Promise( (resolve, reject) => {
            downloadComponent.createObject( app, { resolve, reject, url, form } )
        } )
    }

    Component {
        id: downloadComponent

        NetworkRequest {
            property var resolve
            property var reject
            property var form
            onReadyStateChanged: {
                if (readyState !== 4) return
                if (status < 200 || status >= 299) {
                    reject( qsTr("Download failure %1: Status Code %2: %3").arg(url).arg(status).arg(statusText) )
                }
                if (errorCode !== 0)
                {
                    reject( qsTr("Download failure %1: Error Code %2: %3").arg(url).arg(error).arg(errorText) )
                    destroy()
                    return
                }
                resolve( qsTr("Download success %1: %2").arg(url).arg(responseText.match(/<title>(.*)<\/title>/)[1]) )
                destroy()
            }
            Component.onCompleted: send(...form)
        }
    }
}

 

Output:

 

 

 

7. Promise with async/await

 

 

async/await is another technique to ensure asynchronous Promises are executed in sequential order.

 

This technique, whilst it exists in other Javascript environments such as web development and Node.JS, it does not exist in AppStudio 3.3 and Qt 5.12.1.

 

Please vote to have it implemented here: https://bugreports.qt.io/browse/QTBUG-58620.


The following sample, presently, does not work in AppStudio 3.3, so please treat it as psuedo code.

 

The intention is that it starts 3 download tasks in sequential order, and will stop either when they all complete, or when one has failed. Similar to Promise chaining, the Promise async/await makes writing sequential logic for asynchrous tasks extremely short.

 

import QtQuick 2.12
import QtQuick.Controls 2.5
import ArcGIS.AppFramework 1.0

App {
    id: app

    width: 640 * AppFramework.displayScaleFactor
    height: 480 * AppFramework.displayScaleFactor

    Button {
        text: qsTr("test Promise await")
        onClicked: testPromiseAwait()
    }

    function testPromiseAwait() {
        (async () => {
            try {
                console.log(await download("https://community.esri.com/groups/appstudio"))
                console.log(await download("https://appstudio.arcgis.com"))
                console.log(await download("https://community.esri.com/groups/survey123"))
            } catch (error) {
                console.log(error)
            }
        })()
    }

    function download(url, ...form) {
        return new Promise( (resolve, reject) => {
            downloadComponent.createObject( app, { resolve, reject, url, form } )
        } )
    }

    Component {
        id: downloadComponent

        NetworkRequest {
            property var resolve
            property var reject
            property var form
            onReadyStateChanged: {
                if (readyState !== 4) return
                if (status < 200 || status >= 299) {
                    reject( qsTr("Download failure %1: Status Code %2: %3").arg(url).arg(status).arg(statusText) )
                }
                if (errorCode !== 0)
                {
                    reject( qsTr("Download failure %1: Error Code %2: %3").arg(url).arg(error).arg(errorText) )
                    destroy()
                    return
                }
                resolve( qsTr("Download success %1: %2").arg(url).arg(responseText.match(/<title>(.*)<\/title>/)[1]) )
                destroy()
            }
            Component.onCompleted: send(...form)
        }
    }
}

 

Let's look at the implementation of testPromiseAwait().

 

    function testPromiseAwait() {
        (async () => {
            try {
                console.log(await download("https://community.esri.com/groups/appstudio"))
                console.log(await download("https://appstudio.arcgis.com"))
                console.log(await download("https://community.esri.com/groups/survey123"))
            } catch (error) {
                console.log(error)
            }
        })()
    }

 

The main body is essentially declared as an asynchronous function with the async keyword. And we invoke it, i.e. (async () => { ... }) ().

 

The body of the function is, surprisingly, missing the .then and .catch that we have in our previous examples. Instead, we have the await keyword instead and we consume the results inline.

 

This implementation is more succinct than Promise chaining and Promise nesting.

 

As mentioned at the start, this feature, presently, does not exist in AppStudio. In other Javascript environments such as Web development and Node.JS development, they already have this capability.

 

Again, help us get this feature into AppStudio by voting for it here https://bugreports.qt.io/browse/QTBUG-58620.

 


8. Closing remarks

 

 

I hope this blog helps gives a good introduction of what Promises are. I hope you see some of the Promises code patterns useful code pattern for your AppStudio application.

 

Promises, whilst new in AppStudio 3.3, Qt 5.12.1 is a feature that other Javascript developers have already been enjoying for sometime now.

 

Promises, I believe, work very well with Qt's signal and slot mechanism taking it to a whole new level. Also, with Promises, you may need to look at dynamic creation of components. In this blog, we have examples of both the Timer object and NetworkRequest object wrapped up in components to implement downloads using the Promises.

 

As indicated throughout this post, we need your help to make AppStudio come closer to parity with other Javascript developer environments by voting on getting these two bugs fixed:

 

 

 

9. References

 

 

 

Send us your feedback to appstudiofeedback@esri.com

 

Become an AppStudio for ArcGIS developer! Watch this video on how to sign up for a free trial.

 

Follow us on Twitter @AppStudioArcGIS to keep up-to-date on the latest information and let us know about your creations built using AppStudio to be featured in the AppStudio Showcase.

 

The AppStudio team periodically hosts workshops and webinars; please click on this link to leave your email if you are interested in information regarding AppStudio events.

Outcomes