Arturo Cuya

Join the weekly newsletter

Not a subscriber?

Every week I'll send you cutting edge advice and actionable tips about modern Roku development. State-of-the-art knowledge that you won't find anywhere else — For free.

September 12, 2023 | 13 min read

Using Promises to 10x your async Roku workflows

By Arturo Cuya

@arturo__cuya

The Roku BrightScript language usually handles async operations through observers. This pattern, while powerful, sometimes can lead us to implement anti-patterns that worsen our code readability and make it hard to debug.

Enter rokucommunity/promises, a community-made abstraction over the observable pattern that can help us:

  • Improve async handling readability
  • Easily chain async operations one after the other
  • Run multiple async operations in parallel

In this post I will show you how to setup the library, all the useful methods available (onThen, onCatch, chain, and all for parallel ops) and several async use cases where you’ll want to use the library, including straightforward HTTP requests like this:

promise = request@.getPromise({
	method: "GET",
	url: "https://api.github.com/events"
})

promises.onThen(promise, sub (response as object)
	? "ok?", response.ok
	? "status code:", response.statusCode
end sub)

Noticed the @.getPromise() syntax? That’s a BrighterScript shortcut for callFunc(). If you’re not using BrighterScript yet or you’ve not been able to convince your team to use it, you’re really missing out. Check out this post to learn more about BrighterScript and get a FREE resource that will guarantee success in your migration.

Problems with the observer pattern

For our first example, let’s consider a cinema service that uses dialogs to shows genres, then movies from the selected genre and finally a summary of the selected option.

A normal coding implementation would look like this:

import "utils.bs"

sub init()
    m.catalogNames = utils.getCatalogNames()
    ' PROBLEM #1. See below <<<<<<<<<<<<<<<<<<
    m.selectedCatalogIndex = invalid
    m.selectedMovieIndex = invalid

    showDialog(
        "Welcome to Mango Cinema",
        "What kind of movie do you want to watch today?",
        m.catalogNames,
        "handleCatalogOption") ' PROBLEM #2. See below <<<<<<<<<<<<<<<<<<
end sub

sub showDialog(title as string, message as string, buttons as object, handlerFunc as string)
    dialog = CreateObject("roSGNode", "StandardMessageDialog")
    dialog.update({
        title: title,
        message: [message],
        buttons: buttons
    })

    dialog.observeField("buttonSelected", handlerFunc)
    m.top.dialog = dialog
end sub

sub handleCatalogOption(message as object)
    dialog = m.top.dialog
    ' PROBLEM #3. See below <<<<<<<<<<<<<<<<<<
    dialog.unobserveField("buttonSelected")
    m.selectedCatalogIndex = message.getData()

    showDialog(
        m.catalogNames[m.selectedCatalogIndex],
        "Select a movie",
        utils.getMovieNames(m.selectedCatalogIndex),
        "handleMovieOption"
    )
end sub

sub handleMovieOption(message as object)
    ' PROBLEM #4. See below <<<<<<<<<<<<<<<<<<
    if (m.selectedCatalogIndex = invalid)
        throw "Handled movie options before selecting catalog"
    end if

    dialog = m.top.dialog
    dialog.unobserveField("buttonSelected")
    m.selectedMovieIndex = message.getData()
    movieData = utils.mapMovieIndex(m.selectedCatalogIndex, m.selectedMovieIndex)

    showDialog(
        movieData.name,
        `${movieData.description} Starring: ${movieData.starring.join(". ")}`,
        ["Purchase", "Cancel"],
        "handleFinishProcess"
    )
end sub

sub handleFinishProcess(message as object)
    if (m.selectedCatalogIndex = invalid)
        ' PROBLEM #4. See below <<<<<<<<<<<<<<<<<<
        throw "Handled finish process before selecting catalog"
    end if

    if (m.selectedMovieIndex = invalid)
        throw "Handled finish process before selecting movie"
    end if

    dialog = m.top.dialog
    dialog.unobserveField("buttonSelected")
    dialog.close = true
    ? `Selected catalog: ${m.selectedCatalogIndex}. Selected movie: ${m.selectedMovieIndex}`
end sub

This is, by itself, fairly compact since we’re reusing the showDialog() function across the file. We’ve also pushed utility functions to a utils.bs file (that’s were the getCatalogNames() function and others come from). However, this implementation presents a number of problems that will scale linearly with the number of use cases you need to support.

Let me explain:

  1. For information that needs to be preserved between handlers, you will need to create m scoped variables. Here we have two: m.selectedCatalogIndex and m.selectedMovieIndex.

  2. For every dialog shown, you must define a handler function in the scope of the current component.

  3. We are reusing the same m.dialog pointer to avoid memory leaks. But this requires us to remove the observers on each handler to ensure that unexpected handlers will be fired since all of them are observing the same field.

  4. Since our handler functions are in the component scope, someone else can come without context and try to use them directly. So we need to implement handlers for unintended uses of the functions (throw statements in our case).

Introduction to promises

The rokucommunity/promises library offers an abstraction on top of the observer pattern that solve the four problems mentioned above. Promises work similarly to JavaScript promises, with a few differences caused by how BrightScript works under the hood.

The core of the library is the Promise node:

<component name="Promise" extends="Node">
    <interface>
        <field id="promiseState" type="string" value="pending" alwaysNotify="true" />
    </interface>
</component>

It’s rather simple, just a regular Node with a promiseState that starts at "pending" and can change to "resolved" or "rejected".

You’ll notice that there is no “result” field in the implementation. A Promise result can be anything, but SceneGraph doesn’t let us define XML fields with unknown types. So the promiseResult field is injected by the library right before the promise is resolved.

This simple design creates an abstraction that has fully cross-library compatibility, meaning that it doesn’t matter how or in what context the Promise is resolved, the promise handler will be executed properly. This is different from another community implementation called roku-promise that only works with Task nodes.

rokucommunity/promises installation instructions

⚠️ If you’re using ropm to install the library, the package manager might insert prefixes to your function names to avoid collisions. Double check this by inspecting the roku_modules/ folder that ropm generates.

Creating and handling a promise

All the library methods are encapsulated in a promises namespace. Creating a promise is straightforward:

sub main()
	myPromise = promises.create()
end sub

Resolving a promise is also easy. We pass the promise and an arbitrary value to resolve or reject it.

sub somewhereElse(promise, result)
	if (result = invalid)
		code = 404
		promises.reject(code, promise)
	else
		promises.resolve(result, promise)
	end if
end sub

The onThen and onCatch methods allow us to handle the resolving and rejection of a promise through an anonymous function. The handler function must be able to receive an arbitrary argument, to where the library will pass the resolve or reject value.

sub main()
	myPromise = promises.create()
	promises.onThen(myPromise, sub (result)
		? "promise was resolved with result", result
	end sub)

	promises.onCatch(myPromise, sub (errorCode)
		? "promise was rejected with code", errorCode
	end sub)
end sub

⚠️ The onThen method and all the other Promise handlers only work inside a component scope living on the render thread. Don’t use it on the main.brs file or inside a Task thread.

The library also provides the chain and all methods to handle sequential and parallel promises respectively. Consider the following sequence:

  1. Get the user credentials from the local device registry

  2. Use the credentials to get a fresh user token

  3. Use the token to get the updated user profile

  4. Get the results from 3 different endpoints, each returning recommendations based on location, age and the user’s job respectively.

  5. Show the sum of the recommendations in the UI

  6. Show an error dialog in case there’s a problem

sub main()
	promise = getUserCredentialsPromise()
	
	promises.chain(promise, sub (credentials)
		? "got user credentials", credentials
		return getAuthenticateUserPromise(credentials)
	end sub).then(sub (userId)
		? "authenticated user. got user id", userId
		return getUserProfilePromise(userId)
	end sub).then(sub (profile)
		? "got user profile. user name is", profile.name

		return promises.all([
			getLocationRecomendationsPromise(profile.zip_code),
			getAgeRecomendationsPromise(profile.age),
			getJobRecomendationsPromise(profile.job)
		])
	end sub).then(sub (results)
		? "loaded user recommendations based on location, age and job title"
		resultSum = results[0] ' for location
		resultSum.append(results[1]) ' for age
		resultSum.append(results[2]) ' for user's job

		showRecommendations(resultSum)
	end sub).catch(sub (error)
		? "An error ocurred", error
		showErrorDialog(error)
	end sub)
end sub

The promises.chain() function will take the first promise and the handler for it. The handler must return a new promise that will be handled in the .then() function extended from chain(). Each step of the chain must also return the next promise.

The promises.all() function takes an array of promises and resolves itself when all the passed promises are resolved. The argument that will be passed to the handler function is an array of promise results, in the same order they were declared initially.

The .catch() function will be invoked if the promise is rejected or if any of the handler functions from the chain throws an exception.

Passing context into the handler

You may have noticed that inside the promise handlers we have access to the component scope. This means that we are able to use m variables and any function defined in the component. However, due to the fact that BrightScript doesn’t implement closures, we can’t access any local variable from the inmediate parent scope.

sub main()
	m.pi = 3.14
	magicNumber = 42

	promise = getRadiusPromise()
	promises.onThen(promise, sub (radius as float)
		' we have access to `m.pi`
		result = 2 * m.pi * radius

		' we have access to the methods defined in the component scope
		showResultUi(result)

		' CRASH: magicNumber is uninitialized
		superCircumference = result * magicNumber
	end sub)
end sub

function showResultUi(result as float)
	' ...
end function

To avoid this problem, we can pass a context argument to the onThen method, which can be of any type you want. Make sure to accept a second argument in the handler function too.

sub main()
	m.pi = 3.14
	magicNumber = 42

	promise = getRadiusPromise()
	promises.onThen(
		promise,
		sub (radius as float, context as object)
			`' we have access to `m.pi`
			result = 2 * m.pi * radius
	
			' we have access to the methods defined in the component scope
			showResultUi(result)
	
			' The context object contains our magic number
			`superCircumference = result * context.magicNumber
		end sub,
		' HERE is where we pass the context
		{ magicNumber: magicNumber }
	)
end sub

function showResultUi(result as float)
	' ...
end function

Using Promises to fix the observable pattern problems

Now let’s use the concepts we’ve learned to fix the problems we identified at the beggining of this post. The first thing we’ll need to refactor is the showDialog function to return a Promise.

function showDialog(title as string, message as string, buttons as object) as object
    dialog = CreateObject("roSGNode", "StandardMessageDialog")
    dialog.update({
        title: title,
        message: [message],
        buttons: buttons
    })

    dialog.addField("promise", "node", false)
    dialog.promise = promises.create()

    dialog.observeField("buttonSelected", "handleDialogButtonSelected")
    m.top.dialog = dialog

    return dialog.promise
end function

sub handleDialogButtonSelected(message as object)
    selectedOption = message.getData()
    promises.resolve(selectedOption, message.getRoSGNode().promise)
end sub

We’ll have to add a new field to the dialog node to hold the promise. We could also add the promise to the m scope, but remember that wouldn’t scale well if our component handles multiple different promise flows.

We don’t need to take an additional argument anymore to declare the handler function. Instead, the handleDialogButtonSelected will automatically resolve the promise with the selected option.

Sadly we cannot completely avoid using an observer since the StandardMessageDialog node needs one to handle the user input. But you can definitely use promises instead of observable fields when creating your own components.

With this set, here’s the final implementation:

import "utils.bs"
import "pkg:/source/lib/promises.bs"

sub init()
    m.catalogNames = utils.getCatalogNames()

    promise = showDialog(
        "Welcome to Mango Cinema",
        "What kind of movie do you want to watch today?",
        m.catalogNames
    )

    promises.chain(
        promise,
        { selectedCatalogIndex: invalid, selectedMovieIndex: invalid }
    ).then(function(selectedCatalog as integer, ctx as object) as object
        ' Handle selecting a movie category
        ctx.selectedCatalogIndex = selectedCatalog
        return showDialog(
            m.catalogNames[selectedCatalog],
            "Select a movie",
            utils.getMovieNames(selectedCatalog)
        )

    end function).then(function (selectedMovie as integer, ctx as object) as object
        ' Handle selecting a movie
        ctx.selectedMovieIndex = selectedMovie
        movieData = utils.mapMovieIndex(ctx.selectedCatalogIndex, selectedMovie)

        return showDialog(
            movieData.name,
            `${movieData.description} Starring: ${movieData.starring.join(". ")}`,
            ["Purchase", "Cancel"]
        )

    end function).then(sub (_ as dynamic, ctx as dynamic)
        ' Finish the process
        m.top.dialog.unobserveField("buttonSelected")
        m.top.dialog.close = true
        ? `Selected catalog: ${ctx.selectedCatalogIndex}. Selected movie: ${ctx.selectedMovieIndex}`
    end sub)
end sub

function showDialog(title as string, message as string, buttons as object) as object
    dialog = CreateObject("roSGNode", "StandardMessageDialog")
    dialog.update({
        title: title,
        message: [message],
        buttons: buttons
    })

    dialog.addField("promise", "node", false)
    dialog.promise = promises.create()

    dialog.observeField("buttonSelected", "handleDialogButtonSelected")
    m.top.dialog = dialog

    return dialog.promise
end function

sub handleDialogButtonSelected(message as object)
    selectedOption = message.getData()
    promises.resolve(selectedOption, message.getRoSGNode().promise)
end sub

Here’s how we solve each of the problems:

  1. For information that needs to be preserved between handlers, you will need to create m scoped variables.

We use the context parameter to handle these values

  1. For every dialog shown, you must define a handler function in the scope of the current component.

Now each handler is an anonymous function

  1. We are reusing the same m.dialog pointer to avoid memory leaks. But this requires us to remove the observers on each handler.

Promise handler functions also rely on observers under the hood, but the library removes them automatically on resolve / reject.

  1. Since our handler functions are in the component scope, someone else can come without context and try to use them directly. So we need to implement handlers for unintended uses of the functions.

Now that our handler functions are anonymous, nobody can use them with unintended purposes.

And as a nice bonus, our code is about 30% smaller now.

A wonderful combo: Promises + Requests

So far we’ve seen how to use the promises library for async operations in the render thread. But there are some processes that can only be done in a Task thread, one of them being HTTP requests.

Let’s see how to create a common PromiseTask component that always returns a promise, and then how to extend it to perform HTTP requests, so that we can do something like this:

promise = request@.getPromise({
	method: "GET",
	url: "https://api.github.com/events"
})

promises.onThen(promise, sub (response as object)
	? "ok?", response.ok
	? "status code:", response.statusCode
end sub)

Managing promises inside Tasks

We need two basic features:

  1. Return a promise when creating a Task
  2. Resolve the promise when the Task ends

Similarly to how we made the StandardMessageDialog hold the promise for us, we can extend the Task node to hold a promise as well.

<component name="PromiseTask" extends="Task">
    <script type="text/brightscript" uri="PromiseTask.bs" />
    <interface>
        <field id="promise" type="node" />
        <function name="getPromise" />
    </interface>
</component>

We’ll abstract resolving the promise, so that any node that extends PromiseTask just has to implement the exec() method.

import "pkg:/source/lib/promises.bs"

sub init()
    m.top.functionName = "resolve"
    m.input = invalid
end sub

function getPromise(input = invalid as dynamic) as object
    m.input = input
    m.top.promise = promises.create()
    m.top.control = "run"
    return m.top.promise
end function

' Override me
function exec(input = invalid as dynamic) as dynamic
    throw "Not implemented"
end function

sub resolve()
    try
        result = exec(m.input)
        promises.resolve(result, m.top.promise)
    catch e
        promises.reject(e, m.top.promise)
    end try
end sub

With this set, we can now define a reusable RequestPromiseTask node that extends from PromiseTask:

<component name="RequestPromiseTask" extends="PromiseTask">
    <script type="text/brightscript" uri="RequestTask.bs" />
</component>

Using the rokucommunity/requests library, we can perform a request in one line of code! The caveat is that this only works inside a Task thread, that’s why we have to create the RequestPromiseTask wrapper.

import "pkg:/source/lib/requests.bs"

function exec(input as object) as dynamic
    return Requests_request(input.method, input.url, input.args)
end function

⚠️ If you’re using ropm to install the library, the package manager might insert prefixes to your function names to avoid collisions. Double check this by inspecting the roku_modules/ folder that ropm generates.

With this in place, we can now handle a promise like so:

sub main()
	request = CreateObject("roSGNode", "RequestPromiseTask")
	promise = request@.getPromise({
		method: "GET",
		url: "https://api.github.com/events"
	})

	promises.onThen(promise, sub (response as object)
		? "ok?", response.ok
		? "status code:", response.statusCode
	end sub)
end sub

That’s it! Now you know how to handle your async workflows efficiently - be it in the render thread or within a Task.

If you liked this post, consider subscribing to our newsletter at the end of this post!

Next post will be about the upcoming improved type tracking capabilities for BrighterScript:

' Union types
sub log(data as string or number)
    print data.toStr()
end sub

' Typed arrays
function sum(values as integer[]) as integer
    total = 0
    for each value in values
        total += value
    end for
    return total
end function

' Smarter validations
sub doWork(values as integer[])
    ' Validation error: lcase() expects a string, but values.pop() returns an integer
    print lcase(values.pop()) 
    result = "Hello world"
    ' Validation error: values.push() expects an integer argument
    values.push(result)
end sub

' Complex interfaces
interface DataResponse
  status as number
  data as string
end interface

function getStatusString(response as DataResponse) as string
    if response.status >= 200 and response.status < 300
        return "Success"
    end if
    return "Error"
end function

Some other ways I can help you:

1. Course: A Modern Introduction to Roku Development (Coming April 2024)

For absolute beginners, this course is meant to set you in the right path to make use of modern tools and best practices from day one. Subscribe to the newsletter to get notified when it's available.

2. Open Source tools to boost your productivity:

Use these tools to 10x your workflows. Free and open source forever.

  • create-roku-app: A CLI tool to quickly scaffold Roku apps using the best practices.
  • SceneGraph Flex: A flexbox component for Roku Scenegraph applications.
  • Roku Animate: Define complex Roku Scenegraph animations in one line of code.