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 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)
  • 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
canActivate array [] Guards that must allow navigation before the view is shown (see Route Guards)

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
  • 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, 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()
  .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), 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).


💬 Community & Support


📄 License

Licensed under the MIT License.

About

Router module for Roku

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors