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.
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")
}<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.
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 — 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.
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]] }<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.
' 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, noforward(). - ○ Dynamic
default-focus="{expr}"— currently static-literal only. - ○ Reactive
router.*data reads —router.path/router.params.*/router.isBackJourneyare 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 withrouter.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/
loadingComponenthave 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'scomponent:) can still be gated byloadingComponent, but since it can't callrouter.markReady(), it always falls back toloadingTimeout. - ○ No dynamic path segments — a route is matched as an exact static string, there's no
:id-style URL composition; pass data between routes throughnavigate(path, { params: { ... } })instead.
Exact grammar: GRAMMAR.md. Full feature status: docs/features.md.