Skip to content

rokucommunity/sgRouter

Repository files navigation

sgRouter – Modern View Management for Roku Applications

sgRouter logo

A lightweight, modern router for Roku SceneGraph apps. sgRouter maps URL-style paths to components, manages view lifecycles, handles parameters, and supports route guards — enabling dynamic and seamless navigation experiences.

Build Status Security Audit Downloads Version License Slack Community


🚀 Features

  • URL-style navigation for Roku apps
  • Dynamic routing with parameter support
  • Named routes — navigate by intent, not by hardcoded path strings
  • Route guards (canActivate) for protected screens
  • View lifecycle hooks for fine-grained control
  • Stack management (navigation, suspension, resume, and checkpoint-based unwinding)
  • Observable router state for debugging or analytics

🧩 Installation

Requires Roku Promises

Install via ropm:

npx ropm install promises@npm:@rokucommunity/promises
npx ropm install sgRouter@npm:@rokucommunity/sgrouter

🧠 Core Concepts

Route Configuration

A route defines how your Roku app transitions between views. Routes are typically registered in your main scene.

Each route object can include:

Property Type Required Default Description
pattern string URL-like path pattern ("/details/movies/:id")
component string "" View component to render (must extend sgRouter_View)
name string Stable identifier for named navigation (e.g. "movieDetail")
allowReuse boolean false When true, navigating to the same route calls onRouteUpdate instead of creating a new view
clearStackOnResolve boolean false Destroys all previous views in the stack when this route activates
keepAlive object { enabled: false } When enabled: true, the view is suspended (not destroyed) when navigated away from
suspendMode string "detach" for keepAlive, else "hide" How the view is held while suspended — "detach" (removed from the tree and held in a store, re-attached on resume), "hide" (kept in the tree, hidden and moved off-screen), "show" (kept in the tree, rendered in place). See View suspension
canActivate array [] Guards that must allow navigation before the view is shown (see Route Guards)
outgoingRouteConfigOverrides object Override applied to the outgoing view when navigating to this route (e.g. { suspendMode: "detach" }) — for that navigation only, never mutating the outgoing route's config. Only suspendMode is supported. See outgoingRouteConfigOverrides

View Lifecycle Methods

Views extending sgRouter_View can define:

  • beforeViewOpen → Called before the view loads (e.g. async setup, API calls)
  • onViewOpen → Called after previous view is closed/suspended
  • beforeViewClose → Invoked before a view is destroyed
  • beforeViewSuspend → Invoked before a view is hidden/suspended (before onViewSuspend)
  • onViewSuspend / onViewResume → Handle stack suspensions/resumptions
  • onRouteUpdate → Fired when navigating to the same route with updated params/hash
  • handleFocus → Defines focus handling when the view becomes active

🧱 Example: Main Scene Setup

MainScene.xml

<component name="MainScene" extends="Scene">
    <script type="text/brightscript" uri="pkg:/source/roku_modules/sgrouter/router.brs" />
    <script type="text/brightscript" uri="MainScene.bs" />
    <children>
        <sgRouter_Outlet id="myOutlet" />
    </children>
</component>

MainScene.bs

sub init()
    ' Initialize the router at your main outlet
    sgRouter.initialize({ outlet: m.top.findNode("myOutlet") })

    sgRouter.addRoutes([
        { pattern: "/", component: "WelcomeScreen" },
        { pattern: "/shows", component: "CatalogScreen", clearStackOnResolve: true },
        { pattern: "/movies", component: "CatalogScreen", clearStackOnResolve: true },
        { pattern: "/details/series/:id", component: "DetailsScreen" },
        { pattern: "/details/series/:id/cast", component: "CastDetailsScreen" },
        { pattern: "/details/movies/:id", component: "DetailsScreen" },
        { pattern: "/details/movies/:id/cast", component: "CastDetailsScreen" },
        { pattern: "/:screenName", component: "DefaultScreen" }
    ])

    sgRouter.navigateTo("/") ' Go to the welcome view

    ' set the focus to the router
    sgRouter.setFocus({ focus: true })
end sub

👋 Example: Welcome View

WelcomeScreen.xml

<component name="WelcomeScreen" extends="sgRouter_View">
    <script type="text/brightscript" uri="pkg:/source/roku_modules/promises/promises.brs" />
    <script type="text/brightscript" uri="WelcomeScreen.bs" />
    <children>
        <Label id="label" />
    </children>
</component>

WelcomeScreen.bs

sub init()
    m.label = m.top.findNode("label")
end sub

' Called before the view is shown
function beforeViewOpen(params as dynamic) as dynamic
    m.label.text = "Hello!"
    return promises.resolve(invalid)
end function

🧭 Observing Router State

You can observe routerState for debugging or analytics:

sub init()
    sgRouter.getRouter().observeField("routerState", "onRouterStateChanged")
end sub

sub onRouterStateChanged(event as Object)
    data = event.getData()
    print `Router state changed: ${data.id} ${data.type} ${data.state}`
end sub

Router State Structure:

{
  "id": "",
  "type": "NavigationStart | RoutesRecognized | GuardsCheckStart | GuardsCheckEnd | ActivationStart | ActivationEnd | ResolveStart | ResolveEnd | NavigationEnd | NavigationCancel | NavigationError",
  "url": "",        // present on most events
  "state": {        // present on NavigationEnd and related events
    "routeConfig": {},
    "queryParams": {},
    "routeParams": {},
    "hash": ""
  },
  "error": {}       // only present on NavigationError
}

🔒 Route Guards

Route guards let you allow/deny navigation based on custom logic (e.g., authentication, feature flags). A guard is any node that exposes a canActivate function. The canActivate route config field takes an array of guards — all must pass before the view is shown.

1) Create a Guard (Auth example)

components/Managers/Auth/AuthManager.xml

<?xml version="1.0" encoding="utf-8"?>
<component name="AuthManager" extends="Node">
    <interface>
        <field id="isLoggedIn" type="boolean" value="false" />
        <function name="canActivate" />
    </interface>
</component>

components/Managers/Auth/AuthManager.bs

import "pkg:/source/router.bs"

' Decide whether navigation should proceed.
' Return true to allow, false or a RedirectCommand to block/redirect.
function canActivate(currentRequest = {} as Object) as Dynamic
    if m.top.isLoggedIn then
        return true
    end if

    dialog = createObject("roSGNode", "Dialog")
    dialog.title = "You must be logged in"
    dialog.optionsDialog = true
    dialog.message = "Press * To Dismiss"
    m.top.getScene().dialog = dialog

    ' Redirect unauthenticated users (e.g., to home or login)
    return sgRouter.createRedirectCommand("/login")
end function

2) Register the Guard

Create an instance and expose it globally (so routes can reference it):

components/Scene/MainScene/MainScene.bs (snippet)

' Create AuthManager and attach to globals
m.global.addFields({
    "AuthManager": createObject("roSGNode", "AuthManager")
})

' (Optional) observe auth changes
m.global.AuthManager.observeField("isLoggedIn", "onAuthManagerIsLoggedInChanged")

3) Protect Routes with canActivate

Attach one or more guards to any route using the canActivate array:

sgRouter.addRoutes([
    { pattern: "/", component: "WelcomeScreen", clearStackOnResolve: true },
    { pattern: "/login", component: "LoginScreen" },

    ' Protected content – requires AuthManager.canActivate to allow
    { pattern: "/shows", component: "CatalogScreen", clearStackOnResolve: true, canActivate: [ m.global.AuthManager ] },
    { pattern: "/movies", component: "CatalogScreen", clearStackOnResolve: true, canActivate: [ m.global.AuthManager ] },
    { pattern: "/details/:type/:id", component: "DetailsScreen", canActivate: [ m.global.AuthManager ] },
    { pattern: "/details/:type/:id/cast", component: "CastDetailsScreen", canActivate: [ m.global.AuthManager ] }
])

4) What canActivate should return

  • true → allow navigation
  • false → block navigation (stay on current view)
  • RedirectCommand → redirect elsewhere without showing the target route
    • Create via sgRouter.createRedirectCommand("/somewhere")

5) Accessing the Current Request (optional)

Your guard receives currentRequest with the full navigation context, useful for deep-links or conditional flows:

function canActivate(currentRequest as Object) as Dynamic
    ' currentRequest.route.routeConfig.pattern, currentRequest.route.routeParams, currentRequest.route.queryParams, currentRequest.route.hash, etc.
    if currentRequest?.queryParams?.requiresPro = true and not m.top.isProUser then
        return sgRouter.createRedirectCommand("/upgrade")
    end if
    return true
end function

6) Example: Feature Flag Guard

You can implement a reusable feature flag guard for gradual rollouts:

function canActivate(currentRequest as Object) as Dynamic
    feature = currentRequest?.routeParams?.feature ' e.g. "/feature/:feature"
    if m.global?.features[feature] = true then
        return true
    end if
    return sgRouter.createRedirectCommand("/")
end function

7) Testing Guards Locally

  • Toggle login in development: m.global.AuthManager.isLoggedIn = true
  • Verify redirects by attempting to navigate to a protected route while logged out:
    sgRouter.navigateTo("/shows")
  • Listen to router state changes to confirm block/redirect behavior:
    sgRouter.getRouter().observeField("routerState", "onRouterStateChanged")

The included test project already wires up an AuthManager and protects /shows, /movies, and /details/* routes using canActivate.


🏷️ Named Routes

Named routes let you navigate by a stable identifier instead of a hardcoded path string. If a path pattern ever changes, only the route config needs updating — every navigateTo call site remains valid.

1) Add a name to your routes

sgRouter.addRoutes([
    { pattern: "/",                  component: "WelcomeScreen",  name: "home",        clearStackOnResolve: true },
    { pattern: "/movies/:id",        component: "DetailsScreen",  name: "movieDetail"  },
    { pattern: "/settings",          component: "SettingsView",   name: "settings"     },
])

name is optional — routes without one continue to work exactly as before.

2) Navigate by name

Pass an associative array with a name key instead of a path string:

' Static route — no params needed
sgRouter.navigateTo({ name: "home" })

' Dynamic route — params are substituted into :segment placeholders
sgRouter.navigateTo({ name: "movieDetail", params: { id: 42 } })
' Resolves to: /movies/42

' Extra params beyond what the pattern requires become query parameters
sgRouter.navigateTo({ name: "movieDetail", params: { id: 42, autoplay: true } })
' Resolves to: /movies/42?autoplay=true

String arguments are unchanged — literal path logic runs with zero overhead:

sgRouter.navigateTo("/movies/42")   ' still works exactly as before

3) Backend-driven navigation

Named routes remove the need for client code to reconstruct URL strings from backend responses:

' Backend response: { screen: "movieDetail", id: 42 }
response = m.global.ApiManager.getDeepLink()
sgRouter.navigateTo({ name: response.screen, params: { id: response.id } })

4) Error handling

If the name is not found or a required param is missing, a warning is printed and navigation is cancelled. The history stack is unchanged and no lifecycle hooks are triggered.

sgRouter.navigateTo({ name: "doesNotExist" })
' [WARN] sgRouter: no route found with name "doesNotExist"

sgRouter.navigateTo({ name: "movieDetail" })
' [WARN] sgRouter: missing required param "id" for route "movieDetail" (/movies/:id)

Extra params beyond what the pattern requires are silently appended as query parameters — no warning is logged.

Duplicate names at registration time log a warning and the first registration wins:

' [WARN] sgRouter: duplicate route name "home" — first registration wins (existing: /, ignored: /home)

🧭 Route Snapshot in lifecycle hooks

Every view lifecycle receives a route snapshot so your screen logic can react to the URL that triggered navigation.

What you get in params

beforeViewOpen, onViewOpen, beforeViewClose, beforeViewSuspend, onViewSuspend, and onViewResume all receive a params object constructed by the router just before the lifecycle is called, which includes:

params.route.routeConfig          ' the matched route definition
params.route.routeParams          ' extracted from pattern placeholders (e.g. :id, :type)
params.route.queryParams          ' parsed from ?key=value pairs
params.route.hash                 ' parsed from #hash
params.route.navigationState      ' how this navigation was triggered:
  .fromPushState                  '   true on normal forward navigation
  .fromPopState                   '   true when arriving via goBack() or popToCheckpoint()
  .fromKeepAlive                  '   true when a keepAlive view is resumed
  .fromRedirect                   '   true when arrived via a canActivate guard redirect

The snapshot is sourced from the URL you navigated to (e.g. "/details/movies/42?page=2&sort=trending#grid=poster"). The router builds this object and passes it into beforeViewOpen(params), onViewOpen(params), beforeViewClose(params), beforeViewSuspend(params), onViewSuspend(params), and onViewResume(params).

onRouteUpdate is different — it receives an object with both the old and new route (params.oldRoute and params.newRoute), so you can diff the two and respond to exactly what changed.

Example: Using it in a Catalog view

' CatalogScreen.bs (excerpt)
function beforeViewOpen(params as object) as dynamic
    ' Read route params (e.g., /:type and /:id)
    contentType = params.route.routeParams?.type    ' "shows" or "movies"
    itemId      = params.route.routeParams?.id      ' e.g., "42"

    ' Read query params (?page=2&sort=trending)
    pageIndex = val(params.route.queryParams?.page)    ' 2
    sortKey   = params.route.queryParams?.sort         ' "trending"

    ' Optional: hash fragment (#grid=poster)
    gridMode = params.route.hash

    ' Kick off data loading based on URL snapshot
    ' ... start tasks or fetches here ...

    ' Return a promise to delay opening until ready,
    ' or return true to open immediately and manage loading UI yourself.
    return promises.resolve(invalid)
end function

' If you navigate to the **same route pattern** with different params or hash,
' `onRouteUpdate(params)` will fire (when `allowReuse` is enabled),
' allowing you to update the view without rebuilding it.
' CatalogScreen.bs (excerpt)
function onRouteUpdate(params as object) as dynamic
    oldRoute = params.oldRoute
    newRoute = params.newRoute

    return promises.resolve(invalid)
end function

Where the snapshot comes from

The route snapshot is assembled by the router by parsing:

  • the pattern match result → routeParams
  • the query stringqueryParams
  • the hashhash

That structured object is then provided to the view lifecycles mentioned above. This keeps your screens URL-driven and easy to test (you can navigate with different URLs and assert behavior based on params).


🌗 View Suspension

When you navigate away from a view, the router suspends the outgoing view rather than tearing it down immediately. Two things control this: the suspendMode route option (how the view is hidden) and the beforeViewSuspend lifecycle hook (a chance to run code before it's hidden).

This applies to every outgoing view — both keepAlive views (which are retained for later resumption) and ordinary views (which stay around in the stack for goBack). Only views that are actually destroyed (non-keepAlive views removed by clearStackOnResolve, popToCheckpoint, etc.) go through beforeViewClose instead.

suspendMode

suspendMode decides how a view is held once it has been suspended:

Mode Behaviour
"detach" The view is removed from the SceneGraph tree entirely and held in an internal store, then re-attached when it is resumed. Frees its render/texture cost completely while suspended. This is the default for keepAlive routes.
"hide" The view is kept in the tree but hidden (visible = false) and moved off-screen (translation = [10000, 10000]). This is the default for ordinary (non-keepAlive) routes.
"show" The view is left rendered in place (visible = true, position unchanged). Use this when the outgoing view should remain on screen underneath the incoming one — e.g. a transparent overlay, or a cross-fade you drive yourself.

The default is keepAlive-aware: a keepAlive route defaults to "detach" (it is retained across stack clears, so it pays to free its render cost while suspended), while an ordinary route defaults to "hide". Visibility — and tree membership, for "detach" — is restored on resume.

Unknown suspendMode values fall back to the default for that route (a warning is printed at addRoutes time). Values are case-sensitive.

sgRouter.addRoutes([
    { pattern: "/shows", component: "CatalogScreen", suspendMode: "show" },
    { pattern: "/movies", component: "CatalogScreen", keepAlive: { enabled: true } } ' defaults to "detach"
])

outgoingRouteConfigOverrides — overriding the outgoing view

Sometimes the incoming route knows best how the outgoing view should be suspended. For example, a full-screen player might want the screen underneath it to fully detach, even though that screen normally hides. outgoingRouteConfigOverrides lets the incoming route override the outgoing/suspending view's suspendMode for that one navigation only — it never mutates the outgoing route's registered config.

Only suspendMode may be overridden today; any other key is ignored (with a warning), as is an invalid suspendMode value. This keeps the surface safe — you can't, for example, flip keepAlive on a view that is already mounted and suspending.

There are three ways to supply it, listed lowest → highest precedence (each merges over the previous):

  1. On the incoming route config — applies every time you navigate to that route:
sgRouter.addRoutes([
    { pattern: "/home", component: "HomeScreen" }, ' normally suspends per its own suspendMode
    { pattern: "/player", component: "PlayerScreen", outgoingRouteConfigOverrides: { suspendMode: "detach" } }
])
  1. As a navigateTo option — applies to a single call:
sgRouter.navigateTo("/player", { outgoingRouteConfigOverrides: { suspendMode: "detach" } })
  1. Resolved from the incoming view's beforeViewOpen — when the decision is dynamic (depends on data the incoming view loads):
function beforeViewOpen(params as Object) as Dynamic
    ' ...decide based on params/state...
    return Promises.resolve({ outgoingRouteConfigOverrides: { suspendMode: "detach" } })
end function

The override is applied to the outgoing view's per-navigation route node, used while it suspends, then reverted — so it does not leak onto the view if it is later resumed (e.g. via goBack). It applies on forward navigation (where beforeViewOpen runs); goBack does not trigger it.

beforeViewSuspend

beforeViewSuspend(params) fires on the outgoing view while it is still visible, before it is hidden. Like beforeViewOpen, it can return a promise — the router waits for that promise to resolve before hiding the view and showing the next screen.

The lifecycle ordering is guaranteed to be:

beforeViewOpen(next)      ' the incoming view prepares (data load, etc.)
  → beforeViewSuspend(prev)   ' awaited — outgoing view is still visible here
  → (prev is hidden per suspendMode)
  → onViewSuspend(prev)
  → onViewOpen(next)          ' incoming view is now shown

Because it runs after the next view's beforeViewOpen and blocks until your promise resolves, the hook only proceeds once the next screen is ready. It receives the same route snapshot params as the other lifecycle hooks.

What you do in the hook is up to you — flush analytics, persist scroll position, release resources, and so on. As one example, because the outgoing view is still visible and the router awaits your promise, you could play a fade-out and resolve only when it finishes:

' --- CatalogScreen.bs ---
function beforeViewSuspend(params = {} as object) as dynamic
    ' Resolve any prior pending promise first so it can never dangle if a new
    ' navigation interrupts an in-flight fade-out.
    resolveSuspendPromise()

    ' Create a deferred and return it; resolve it when the fade-out completes.
    m.suspendPromise = promises.create()
    m.fadeOutInterpolator.keyValue = [m.container.opacity, 0]
    m.fadeAnimation.observeField("state", "onFadeAnimationStateChanged")
    m.fadeAnimation.control = "start"
    return m.suspendPromise
end function

sub onFadeAnimationStateChanged(event as object)
    if event.getData() = "stopped" then resolveSuspendPromise()
end sub

' Resolves the pending promise exactly once and stops observing — idempotent, so
' it's safe to call from the observer, on re-entry, or on teardown.
sub resolveSuspendPromise()
    m.fadeAnimation.unobserveField("state")
    if m.suspendPromise <> invalid then
        promise = m.suspendPromise
        m.suspendPromise = invalid
        ' Resolve the deferred — pass the promise as the SECOND argument
        promises.resolve(true, promise)
    end if
end sub

⚠️ If you return a promise, make sure it always resolves. Because the router awaits beforeViewSuspend, a promise that never resolves will stall navigation. Routing every resolution through a single idempotent helper (as above) guarantees a pending promise is always settled — even if a fade is interrupted by a re-entrant navigation.


⏪ Checkpoints — Unwind the Stack to a Known Point

Checkpoints let you mark a stack entry as a named "save point" and later unwind back to it in one call. This is useful for multi-step flows (e.g. checkout, onboarding, sign-in) where you want to jump back to a known entry point without calling goBack repeatedly.

1) Mark a checkpoint

Call setCheckpoint at any time to stamp the currently active stack entry:

' Mark the current view with a named identifier
sgRouter.setCheckpoint("checkout-start")
  • identifier is a string. If omitted (or Invalid), the current route's path is used as the identifier.
  • A single stack entry can hold multiple identifiers — calling setCheckpoint again with a different name on the same view just adds another identifier.
  • Returns false if the stack is empty.
' Omit the identifier — uses the active route path automatically
sgRouter.setCheckpoint()

2) Unwind to a checkpoint

Call popToCheckpoint to close every view above the most recent matching checkpoint and restore that view as active:

sgRouter.popToCheckpoint("checkout-start")
  • Searches backwards from the entry below the current view.
  • If identifier is omitted (or Invalid), finds the most recent entry that has any checkpoint.
  • Returns a promise that resolves when the target view is active.
' Pop to the most recent checkpoint regardless of identifier
sgRouter.popToCheckpoint()

Example: Multi-step checkout flow

' --- CatalogScreen.bs ---
function onViewOpen(params as object) as dynamic
    ' Mark this view as the start of the checkout flow
    sgRouter.setCheckpoint("shop")
    return promises.resolve(invalid)
end function

' --- CheckoutConfirmScreen.bs ---
function onCancelPressed() as void
    ' Jump all the way back to the catalog in one call
    promises.chain(sgRouter.popToCheckpoint("shop"), m)
        .catch(function(error, m)
            print "popToCheckpoint failed: " + error.message
        end function)
        .toPromise()
end function

Stack behaviour

  • Views above the target — removed from the active navigation stack. Non-keepAlive views are closed and destroyed; keepAlive views that were already suspended remain suspended (the checkpoint operation does not affect their suspended state).
  • The target view — if it was suspended it is restored to viewTarget (re-attached, if it was detached); onViewResume fires as normal.
  • History stack — truncated to [0..targetIndex] for navigation purposes. A goBack immediately after popToCheckpoint sees only the entries up to and including the target, even though suspended keepAlive views above the target are still retained.
' Stack: /home [checkpoint="shop"] → /details/42 → /cart → /checkout
sgRouter.popToCheckpoint("shop")
' Stack after: /home
' /details/42, /cart, and /checkout are removed from navigation; non-keepAlive views are destroyed, while suspended keepAlive views remain suspended; /home is the active view

Error cases

popToCheckpoint rejects (returns a rejected promise) in the following situations:

Situation Rejection message
No matching checkpoint found in the history stack "popToCheckpoint: no matching checkpoint found in history stack"
Another navigation is already in progress "Navigation already in progress"
promises.chain(sgRouter.popToCheckpoint("checkout-start"), m)
    .then(function(_, m)
        ' successfully popped to checkpoint
    end function)
    .catch(function(error, m)
        print "popToCheckpoint failed: " + error.message
    end function)
    .toPromise()

💬 Community & Support


Accepted security advisories

Dependencies flagged by npm audit that we have reviewed and chosen not to upgrade are tracked in audit-ci.jsonc. Each entry includes the advisory ID, the date it was added, and the reason it does not apply to this project.


📄 License

Licensed under the MIT License.

About

Router module for Roku

Resources

License

Stars

6 stars

Watchers

1 watching

Forks

Packages

 
 
 

Contributors