🧭

Router
router.navigate, RouterOutlet, default-focus, the vacuum rule.

A built-in, schemaless, zero-declaration global router — one router.* namespace for both actions (navigate/back/...) and data reads (router.path/router.params.*).

Declaring routes and mounting the outlet

router.setRouting([...]) declares the whole route tree once (nesting via children), and <FlashTheaterRouterOutlet> renders whichever route currently matches. Any number of outlets may be mounted, nested arbitrarily — a parent route's persistent chrome (a sidebar menu, say) never rebuilds just because a deeper sibling route changes.

MainScene.thr (excerpt)
public function setup() {
  m.top.backgroundColor = "0x101010FF"
  router.setRouting([
    { path: "splash", component: "SplashScreen" },
    {
      path: "browse",
      component: "Shell",
      children: [
        { path: "", component: "HomeScreen" },
        { path: "schedule", component: "ScheduleScreen" }
      ]
    }
  ])
  router.navigate("/splash")
}
MainScene.thr (excerpt)
<component extends="Scene">
<FlashTheaterRouterOutlet id="rootOutlet" />
</component>

Navigating, params, and back-journey data

router.navigate(path, [params], [skipInHistory]), router.back(), router.resetHistory(). router.appendBackJourneyData(data)/updateBackJourneyData(data) stash data on the current history entry, readable later via router.backJourneyData.* once the user navigates back to it — genuinely surviving a full destroy-and-recreate round trip through history, not just a same-instance field.

HomeScreen.thr (excerpt)
private function goToSchedule(key: string, press: boolean) {
  if (press) {
    router.updateBackJourneyData({ visitedSchedule: true })
    router.navigate("/browse/schedule", { day: "Mon" })
  }
}

derived welcomeText: string = pickWelcomeText(router.backJourneyData.visitedSchedule)
' Inside ScheduleScreen.thr, reached via /browse/schedule?day=Mon
day = router.params.day

router.isBackJourney reads the same schemaless way — true when the current mount was reached via router.back(), false for an ordinary forward router.navigate(...). See "Directional focus" below for what it's for.

Back key — automatic, no wiring needed

The Scene-rooted component's generated onKeyEvent walks the router's history on the physical "back" key with zero on:key[back] handler required anywhere — it stops consuming the key once history is empty, so Roku's own default app-exit behavior takes over.

Focus after a navigation

Every router-mounted component gets an automatic setup() call, and focus lands on the newly-mounted screen's default-focus="true" element — but only following the vacuum rule: if something else (a persistent menu) already holds focus, navigating never rips it away.

A route also remembers whatever element was last genuinely focused anywhere inside its own mounted content — including inside a nested custom component, e.g. a list row — and restores it automatically the next time the same route mounts, in either direction (router.navigate(...) or router.back()), even across a brand-new component instance and even for a dynamically-created {#each} element. Nothing to author for this — it's automatic. The vacuum rule still governs whether it's ever actually observed, though: restoration only ever applies when returning creates a genuine focus vacancy (whatever holds focus at that moment is also being destroyed by the same navigation). If you've manually stepped back to a persistent menu before triggering the navigation, that menu keeps focus — the route's own content memory is still captured underneath, ready for the next time a real vacancy occurs, it just has no visible effect while something else remains legitimately focused.

Directional focus — router.isBackJourney

Sometimes a route genuinely wants a different initial focus depending on how it was entered — a multi-step flow whose own "continue" action should be focused on a fresh forward visit, but whose own "review/edit" action should be focused when the user has stepped away and come back to revisit it. Combine router.isBackJourney with an explicit focus(<id>) call — always wins over both the vacuum rule and the automatic restoration above — from the mounted component's own setup():

DirectionalFocusDemo.thr (excerpt)
' DirectionalFocusDemo.thr — the same route wants a DIFFERENT initial focus
' depending on how it was entered.
public function setup() {
  if (router.isBackJourney) {
    focus("buttonB")
  } else {
    focus("buttonA")
  }
}

See apps/sample-app's DirectionalFocusDemo.thr/ DirectionalFocusDemoDetail.thr (reachable from Home's own second prompt) for the full, live-verified round trip: press OK on button A (focused on the forward visit) to go to the detail step, then OK there to come back — button B is focused this time, not A, proving the branch is genuinely direction-aware.

Router-outlet transitions

navigate-out:/navigate-in:/back-out:/back-in: on <FlashTheaterRouterOutlet> animate the swap instead of instantly replacing it — same value grammar as animation's own transition:/in:/out: (a built-in preset or a declared animation name). The direction is read straight off whether the swap came from router.navigate(...) or router.back() — nothing to author on the caller's side. Every one of the four must target the OUTLET itself (never the screen it's mounting) — a dynamically-created routed screen has no compile-time id an animation could reference, so the outlet's own translation is what actually animates: the outgoing screen's out: plays, the outlet is torn down and teleported to the in: animation's own starting position (e.g. off-screen on the opposite side), then the new screen mounts and slides into place. Only one screen is ever visible at once — not two screens co-mounted and cross-fading.

Shell.thr (excerpt, inside <script>)
animation slideOutLeft     { target: outlet, duration: 0.25, translation: [[0, 0], [-1280, 0]] }
animation slideInFromRight { target: outlet, duration: 0.25, translation: [[1280, 0], [0, 0]] }
animation slideOutRight    { target: outlet, duration: 0.25, translation: [[0, 0], [1280, 0]] }
animation slideInFromLeft  { target: outlet, duration: 0.25, translation: [[-1280, 0], [0, 0]] }
Shell.thr (excerpt, inside the template)
<FlashTheaterRouterOutlet
  id="outlet"
  width="1280" height="720"
  navigate-out:slideOutLeft navigate-in:slideInFromRight
  back-out:slideOutRight back-in:slideInFromLeft
  loadingComponent="BusySpinner"
  loadingMinDuration="0.2" loadingTimeout="5"
/>

Loading gate — loadingComponent + router.markReady()

loadingComponent names a SceneGraph node to show (Roku's built-in BusySpinner, or any custom .thr component) while the just-mounted screen isn't ready yet — the outlet creates and centers it itself, on width/height (its own declared content-area size; FlashTheaterRouterOutlet has no size of its own to derive that from). A screen signals real readiness with router.markReady() — a genuine gate on real work (a fetch, say), not a cosmetic timer. Unlike every other router.* action, markReady() doesn't touch the router singleton at all: it flips a plain field (ft_routeReady) on the CALLING component's own top node, which every compiled .thr component declares unconditionally. A screen that never calls it still reveals — loadingTimeout (default 5s) forces it — so this feature is opt-in, never a way to accidentally strand a screen behind a spinner forever. Calling markReady() synchronously inside setup() itself (the common case, when nothing needs waiting on) reveals immediately with no spinner ever shown at all — the gate only actually waits when readiness genuinely isn't known yet the moment setup() returns.

RouterTransitionDemo.thr (excerpt)
' Inside the routed screen, once real data has arrived:
private function onPostsLoaded(result: dynamic) {
  state resultText = "Loaded " + result.count.ToStr() + " posts"
  router.markReady()
}

A navigation into a screen gated this way stays focus-free while it loads, then applies whichever focus target the mount cascade settled on — including the route-scoped restoration described above — once the gate actually clears. Nothing extra to author for the loading-gate case specifically; it composes with the ordinary focus mechanics for free.

When more than one nested outlet gates a mount in the same navigation (an ancestor re-rendering persistent chrome around a deeper route change), only the INNERMOST one actually shows its loadingComponent — an outer outlet mid-transition at the same time still genuinely waits on its own child's readiness, it just never displays a second, competing spinner.

Reference implementation — apps/router-demo

Every mechanism on this page has a router-mounted, scaled chapter in apps/router-demo — 4 chapters (/navigate-and-params through /loading-gate), reachable with REWIND/FAST-FORWARD once compiled and sideloaded. Unlike every other chapter app, where the router is just plumbing for that app's own chapter-to-chapter navigation, this one treats the router's own behaviors as the actual subject being taught: chapter 1 nests a real "list" -> "detail" -> back round trip to exercise router.params.* and router.backJourneyData.* against genuine navigation, not a synthetic stand-in; chapter 2 is the directional-focus round trip described above, adapted from apps/sample-app's own DirectionalFocusDemo.thr; chapter 3 narrates the outlet transitions every chapter switch in this app is already playing, and explicitly contrasts REWIND/FAST-FORWARD (always navigate-out:/navigate-in:) against the physical Back key (the only thing in the app that ever plays back-out:/back-in:); chapter 4 demonstrates both loading-gate shapes — a default variant that defers router.markReady() behind a short simulated delay, and a customized variant that never calls it at all, relying purely on loadingTimeout to force the reveal. See findings/router-demo-app.md for what each chapter covers and findings/demo-app-conventions.md for the app-structure convention it follows.

⚠️ Not (yet) supported

  • ○ Middleware/route guards (canActivate, redirects) — no async guard sequencing in this compiler's synchronous codegen yet.
  • ○ Forward navigation — back() pops a plain stack, no forward().
  • ○ Dynamic default-focus="{expr}" — currently static-literal only.
  • ○ Reactive router.* data reads — router.path/router.params.*/router.isBackJourney are plain snapshots, not live-bound (the router's own reactivity is a fresh remount, not a bound expression).
  • ○ Route-scoped focus restoration only re-identifies a remembered element by its own id — an element with none can never be re-identified on a later mount (its nearest id-bearing ancestor, if any, is still tried), and the re-identifying search is app-wide, so two different components reusing the same static author-chosen id could in principle collide.
  • ○ Restoration is only ever OBSERVED when returning to a route creates a genuine focus vacancy — the vacuum rule never steals focus from something still legitimately held (e.g. a persistent menu the user manually returned to before navigating away). Use focus(<id>), optionally combined with router.isBackJourney (see "Directional focus" above), to force a specific target regardless of vacuum state.
  • ○ A plain (non-router-mounted) child component never gets an automatic setup() call at all — only a router-mounted screen and the root Scene do.
  • ○ Router-outlet transitions/loadingComponent have no per-route override — they're configured once, on the outlet itself, applying to every route it ever mounts.
  • ○ A router-mounted screen that isn't compiled from .thr (a hand-authored component referenced directly as a route's component:) can still be gated by loadingComponent, but since it can't call router.markReady(), it always falls back to loadingTimeout.
  • ○ No dynamic path segments — a route is matched as an exact static string, there's no :id-style URL composition; pass data between routes through navigate(path, { params: { ... } }) instead.

Exact grammar: GRAMMAR.md. Full feature status: docs/features.md.