Promises How-To & Style Guide
How to do async work in JellyRock with @rokucommunity/promises, when to reach for a
promise vs. blocking fetchRes, and the patterns to avoid. For the why and shape of the
model (and how it layers over the task pool), see async.md.
The one-sentence rule: on the render thread, prefer
fetchAsync(...).then(...); in a Task thread, keep using blockingfetchRes/fetchJsonfor linear/branching control flow. The full rationale is decisionpromise-native-interface-fetchres-exceptionindocs/decisions.md.
The canonical call shape
Section titled “The canonical call shape”fetchAsync(req, requestId) submits a request to the existing task pool and returns a Promise
node. Chain it with promises.chain(...):
import "pkg:/source/api/apiPromise.bs"
sub loadNextEpisode() req = GetApi().BuildGetNextEpisodeRequest(m.itemId) promises.chain(fetchAsync(req, "nextEpisode-" + m.itemId)).then(sub(res as object) ' Any HTTP response lands here — including 4xx/5xx. Inspect res.ok. if res.ok then m.nextEpisode = res.json end sub).catch(sub(err as object) ' Only transport failures / timeouts land here (see "Error contract"). m.log.warn("next-episode fetch failed", err.reason) end sub)end subNo
_line-continuation. BrighterScript does not support the classic BrightScript_continuation character. Chain by placing.then(/.catch(directly after the previous callback’send sub)on the same line (as above) — not on a new line with a leading dot.
reqis the AA from anyGetApi().Build*Request()method (same inputfetchRestakes).requestIdis a unique string per in-flight request — it’s the registry key and the id the pool echoes back. Reusing an id across two concurrent requests is a bug (same as the pool’s existing contract). A"<purpose>-<itemId>"shape works well.
Defer per-endpoint GetApi().*Async() sugar — the single generic fetchAsync over the
Build*Request() methods is the whole surface. Add wrappers only if call sites ask for them.
The error contract (decision #5 — fetch() convention)
Section titled “The error contract (decision #5 — fetch() convention)”This mirrors the web fetch() convention so .catch stays meaningful:
| Outcome | Lands in | Why |
|---|---|---|
2xx response | .then(res) | success; res.ok = true |
4xx / 5xx response | .then(res) | an HTTP reply did arrive; inspect res.ok / res.statusCode. Expected 404 responses flow as data. |
Transport failure (no HTTP reply; statusCode <= 0) | .catch(err) | the request never completed |
Timeout (timeouts.API_WAIT_MS) | .catch(err) | the pool slot never answered |
Pool unavailable / invalid req | .catch(err) | nothing was submitted |
So .then is not “success” — it’s “the server answered.” Branch on res.ok inside .then
for HTTP-level errors; reserve .catch for “the network/pool failed us.” The reject value is an
AA with ok: false, statusCode: 0, and a reason ("timeout" / "pool-unavailable").
Render-thread vs. in-Task consumption
Section titled “Render-thread vs. in-Task consumption”- Render thread (the common case): chain
.then/.catchas above. Delivery is automatic — the library observes the promise on the render thread and fires your callbacks on the next tick. You do not manage a message port. - In a Task thread: if you ever consume a promise inside a Task’s run loop, use
promises.setMessagePort(port)+promises.wait2(timeoutMs, port)so promise events are pumped alongside your other events. In practice you rarely need this — Task code should use blockingfetchRes(see below), not promises. - On the main thread (
main.bs’s event loop): you can’t callfetchAsyncdirectly. The adapter bridges the pool via a named-functionobserveField, which Roku only dispatches inside a SceneGraph component (the render thread).main.bs’sMain()runs on the main BrightScript thread (wait(0, m.port)), so named observers never fire there — that’s why every observation inmain.bsis port-based. Delegate the async work to a render-thread component method viacallFuncinstead:callFuncrendezvouses to the node’s render thread, so afetchAsync().then()inside that method runs where the adapter works. The canonical example ismain.bs’s button router invokinggroup.callFunc("toggleFavorite")(see Canonical examples below). Do not wiresetMessagePort/wait2into themain.bsloop for this — delegation is simpler and keeps the one async vocabulary.- The delegated method MUST be declared in the component’s
<interface>as<function name="toggleFavorite" />—callFunconly dispatches to exposed functions, and a missing declaration is a silent no-op the transpiler won’t catch (it shipped a dead watched toggle once). Thecallfunc-interfaceBSC plugin now makes this a build error; seebuild-and-tooling.md.
- The delegated method MUST be declared in the component’s
Parallel requests
Section titled “Parallel requests”Use promises.all([...]) when requests are genuinely independent and you need all results:
promises.chain(promises.all([ fetchAsync(reqA, "a"), fetchAsync(reqB, "b")])).then(sub(results as object) ' results[0], results[1] — both resolved (any HTTP response resolves). ' all() rejects on the FIRST transport failure / timeout.end sub)When to use a promise vs. blocking fetchRes (the Option A rule)
Section titled “When to use a promise vs. blocking fetchRes (the Option A rule)”Decision promise-native-interface-fetchres-exception keeps two async tools on purpose:
- Promise (
fetchAsync) — the default for render-thread flows and any non-blocking work. The render thread must never block, so a promise is the only correct tool there. - Blocking
fetchRes/fetchJson— stays the tool for Task-internal control flow:- the bootstrap path (login / server discovery, before the pool is up), and
- linear or branching task orchestrators. The worked example is
QuickPlayTask.doSeries: a 3-branch resume→next-up→shuffle tree. Flattened onto.thenchains it reads worse — you’d threadcontext.satisfiedguard flags through every stage. Task threads can block safely, so blocking is the Roku-idiomatic, more-readable choice there. Don’t rewrite hot, working orchestrators (QuickPlayTask~32 fetches,LoadItemsTask~22,items.bs) intowait2promise-loops — that’s pure regression risk for negative readability.
Long-term: once BrighterScript ships async/await,
await fetchAsync(...)restores linear readability with the promise model and this two-tool split gets revisited. Until then, the split is deliberate.
Collapsing a pure-fetch Task (decision #4)
Section titled “Collapsing a pure-fetch Task (decision #4)”The biggest DX win is deleting Task components that exist only to move one fetchRes off the
render thread. Collapse criteria:
- Collapse → render-thread promise: a Task that does pure I/O (one or a few
fetchRes, no heavy transform) consumed viacreateObject+observeField. Replace with afetchAsync(...).then(...)at the call site and delete the.xml+.bspair. Worked example:3abelow (GetNextEpisodeTask→VideoPlayerView.fetchNextEpisode). - Keep as a Task: a Task doing array processing / heavy data transforms. That work must stay off the render thread — collapsing it would move the transform onto the render thread, which the render-thread-protection rule forbids. The promise only moves the I/O wait, not the CPU work.
Cancellation — you get it for free
Section titled “Cancellation — you get it for free”A pending promise must never fire a callback into a destroyed node. You don’t wire this by hand:
the auto-abandon-promises BSC plugin injects abandonApiPromises() into your component’s
onDestroy() at build time (and errors if a fetchAsync-calling component has no
onDestroy). Base JRScreen.bs / JRGroup.bs carry it as a floor. Just write your normal
onDestroy(); abandon is added for you. See async.md
for the mechanism.
Patterns to avoid
Section titled “Patterns to avoid”- Treating
.thenas “success.” A 404/500 resolves. Branch onres.okinside.then. - Swallowing rejections in
.finally. As of@rokucommunity/promises0.6.0,.finally()no longer suppresses a rejection — a rejection still propagates past.finally. Put real error handling in.catch, not.finally. - Forcing
wait2into linear Task code. If it’s a Task orchestrator, usefetchRes. - Reusing a
requestIdacross concurrent in-flight requests. - Manually calling
abandonApiPromises()and also relying on the plugin — it’s idempotent, but the plugin already injects it; don’t hand-write the call.
How to test promise-based code
Section titled “How to test promise-based code”Drive the adapter’s resolve/reject decision and the abandon model directly — see
tests/source/unit/api/apiPromise.spec.bs for the
pattern. Key points:
apiPromiseShouldResolve(res)is the pure resolve-vs-reject decision — assert it for the2xx/4xx/5xx/ transport / timeout cases with no pool or async pump needed.- For settle/abandon, populate a registry AA and call
settleApiPromiseIn(pending, requestId)/abandonApiPromisesIn(pending)(the registry is an explicit parameter precisely so it’s testable — bare calls from a Rooibos class method don’t share the instancem). Assert the promise’spromiseState/promiseResultsynchronously. - Run on hardware:
npm run test:tdd(single spec) →npm run test:unitbefore commit.
Canonical examples (copy these)
Section titled “Canonical examples (copy these)”The three Phase-3 reference migrations, each verified on hardware. They cover the three real shapes you’ll hit:
| # | Pattern | Where | What it shows |
|---|---|---|---|
3a | Collapse a pure-fetch Task | VideoPlayerView.fetchNextEpisode | A whole .xml+.bs Task (GetNextEpisodeTask) deleted; one fetchRes becomes a render-thread fetchAsync().then().catch(). The biggest DX win. |
3b | Render-thread submitApiRequest+observeField → promise | ItemDetails.checkTrailerAvailability | Swaps a named-observer result node for fetchAsync().then(). Uses the context AA to drop a result that lands after the user navigated away (no closures in BS). |
3c | Main-thread caller → render-thread promise via callFunc | ItemDetails.toggleFavorite, invoked from main.bs’s button router | The favorite toggle moves off main.bs’s god-loop. main.bs (main thread) calls group.callFunc("toggleFavorite"); the method runs on the render thread where fetchAsync works. Exercises the error contract (revert button + toast when res.ok is false or on reject). |
Why no “two dependent calls” example? Dependent fetch sequences in this codebase live inside the Task orchestrators (
QuickPlayTask,LoadItemsTask, …) that decision #3 deliberately keeps as blockingfetchRes. Render-thread components almost always fire a single request, so there’s no honest render-thread chain to migrate. For the chaining + error-propagation pattern, see the sequential-chain example under “Parallel requests” above and the library’s README (auth → profile → image).