Skip to content

Add canDeactivate guard support #67

@iObject

Description

@iObject

canDeactivate Guards

Block navigation away from a route based on application state. canDeactivate guards mirror the existing canActivate API and are the standard way to implement "unsaved changes" dialogs and playback protection in Roku Router.


Why canDeactivate?

Roku Router already supports canActivate guards to protect routes from being entered. However there is currently no equivalent mechanism to protect routes from being left. This creates a gap for common application patterns that require the user to confirm or complete an action before navigation is allowed to proceed.

Two particularly common cases on Roku are:

  • Unsaved changes — a user has edited a form or profile screen and attempts to navigate away without saving. The application needs to intercept that navigation and present a confirmation dialog before allowing the route to deactivate.
  • Playback protection — a user is in an active playback or purchase flow and presses back. Silently navigating away could interrupt a transaction or cause a poor user experience; the application needs to block or confirm the navigation first.

Without canDeactivate, developers work around this by intercepting remote key events directly on the view, which is fragile, inconsistent with the routing model, and bypassed entirely by programmatic navigateTo() calls. A first-class canDeactivate guard resolves this cleanly and consistently.


Route Configuration

Add an optional canDeactivate array to any route definition, alongside canActivate:

{ pattern: "/player/:id", component: "PlayerScreen",
    canActivate:   [m.global.AuthManager]
    canDeactivate: [m.global.PlaybackGuard]
}
  • canDeactivate is optional — omitting it leaves deactivation behaviour unchanged
  • canDeactivate accepts an array of one or more guard references
  • A route may define both canActivate and canDeactivate independently

Usage

Playback protection

' PlaybackGuard.brs
function canDeactivate(ctx as Object) as Boolean
    ' ctx.currentRoute  - the route being left
    ' ctx.targetRoute   - the route being navigated to
    if m.global.PlaybackManager.isPlaying then
        ' Show a confirmation dialog and block until resolved.
        ' Return true to allow, false to block.
        return m.global.DialogManager.confirm("Stop playback and leave?")
    end if
    return true
end function

Unsaved changes

{ pattern: "/profile/edit", component: "ProfileEditView",
    canDeactivate: [m.global.UnsavedChangesGuard]
}
' UnsavedChangesGuard.brs
function canDeactivate(ctx as Object) as Boolean
    if m.global.FormState.isDirty then
        return m.global.DialogManager.confirm("Discard unsaved changes?")
    end if
    return true
end function

Guard Evaluation

Each guard in the canDeactivate array is called in order with a context object:

Context key Value
ctx.currentRoute The route being left
ctx.targetRoute The route being navigated to (Invalid if exiting the app via back())
  • If any guard returns false, navigation is cancelled immediately — subsequent guards in the array are not evaluated
  • If all guards return true, navigation proceeds normally through the rest of the routing pipeline
  • Guards are synchronous and return a plain Boolean — async patterns (e.g. showing a dialog) must block the BrightScript execution thread until the user responds before returning

Cancellation Behaviour

When a canDeactivate guard returns false:

  • The current route remains active and fully mounted — no lifecycle hooks (_beforeViewClose etc.) are triggered on it
  • The destination route is not mounted and no history stack changes are made
  • The router surfaces a cancellation signal (return value or router event) so the calling view can respond (e.g. restore focus to a button)

How the Pipeline Works

canDeactivate is evaluated before any other routing logic — there is no point evaluating the destination if the current route cannot be left.

  1. canDeactivate guards on the current route
  2. canActivate guards on the destination route
  3. _beforeViewClose on the current view
  4. View deactivation / removal
  5. Destination view creation and _beforeViewLoad / _onViewLoaded lifecycle

canDeactivate is triggered consistently by both navigateTo() and back() — the guard cannot be bypassed through either path. Deep link navigations that replace the current route also trigger canDeactivate.


Notes

  • Async / promise-returning canDeactivate guards are not currently supported — synchronous Boolean only
  • A per-navigation bypass mechanism (e.g. a force flag to skip guards) is not currently supported and may be added as a follow-up
  • A global fallback canDeactivate guard applied to all routes is not currently supported

Metadata

Metadata

Assignees

Labels

enhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions