Skip to content

3. SusScreen — screens, lifecycle, and wiring ​

A screen (SusScreen) is a full-screen view managed by SusRouter. It inherits SusComponent, so it has the full core reactive toolkit (Prop<T>, Watch, Build(), Mounted()), plus router lifecycle and access to route parameters.


1. Minimal screen ​

csharp
using UnityEngine.UIElements;
using Sharq.Router;

public class HomeScreen : SusScreen
{
    protected override void Build()
    {
        AddToClassList("home-screen"); // companion USS: .home-screen { flex-grow: 1; }
        Add(new Label("Home"));

        var btn = new Button { text = "Go to About" };
        btn.RegisterCallback<ClickEvent>(_ => Router.Push("/about"));
        Add(btn);
    }
}

Layout on the screen root is a USS class like any other component, not style.flexGrow — see Design tokens §1.4.

Build() builds the tree (from SusComponent). Router and Props are not set yet in Build() — read them from OnBeforeEnter() / OnEntered().


2. Lifecycle — override On* methods ​

⚠️ Important: public BeforeEnter/Entered/BeforeLeave/Leaving/Left/BeforeRouteUpdate are called by the router itself — do not override them. Override the protected virtual On* hooks (template-method pattern).

csharp
public class BattleScreen : SusScreen
{
    // 1. Enter validation + reading Props. false = enter cancelled.
    protected override bool OnBeforeEnter(SusRoute fromRoute)
    {
        var matchId = GetParam("matchId");
        return !string.IsNullOrEmpty(matchId);
    }

    // 2. Screen is already in the DOM — start animations, load data.
    protected override void OnEntered() => StartBattle();

    // 3. Props changed on the SAME screen instance (e.g. /users/1 → /users/2).
    protected override bool OnBeforeRouteUpdate(SusRoute toRoute) => true;

    // 4. Leave guard. false = navigation blocked (e.g. dirty form).
    protected override bool OnBeforeLeave(SusRoute toRoute)
    {
        if (_isDirty)
        {
            Router.Modal(typeof(ConfirmLeaveDialog), new() { ["message"] = "Leave without saving?" });
            return false;
        }
        return true;
    }

    // 5. Navigation is committed and the screen is still in the tree —
    //    undo anything it changed on its parents (shell captions, classes).
    protected override void OnLeaving(SusRoute toRoute) => ClearShellCaption();

    // 6. Screen is removed and was not cached — unsubscribe, release resources.
    protected override void OnLeft() => CleanupBattle();
}

Full sequence ​

Push("/battle/42"):
  1. Activator.CreateInstance / LazyFactory()
       Created()              ← SusComponent; Router/Props NOT set yet
       Build()                ← tree is built
  2. screen.Router = router
  3. screen.Props = params + query + DefaultProps + PropsFn(...)
  4. OnBeforeEnter(from)      ← false = cancel entire navigation
  5. history updates, SusRouteView swaps screen + transition.PlayIn()
  6. OnEntered()             ← screen in DOM
  7. (next frame) Mounted()   ← deferred SusComponent hook

Leave:
  8. OnBeforeLeave(to)       ← guard, false = cancel
     (then CanLeave, BeforeEach, CanEnter, per-route BeforeEnter)
  9. OnLeaving(to)           ← leave/enter guards passed; screen still in the tree
 10. (BeforeResolve, target BeforeEnter — last cancellation points)
 11. OnLeft()               ← once, leaf → root, screen still in the tree
 12. SusRouteView removes screen (or moves it into the KeepAlive cache)
 13. Unmounted()            ← SusComponent; NOT called on KeepAlive

With KeepAlive the screen is not recreated and does not get OnLeft() when you leave it: the instance is detached into the cache (not destroyed); on return — OnBeforeEnter() / OnEntered() again without a new Build(). OnLeft() arrives later, when the cache evicts it (see below).

The OnLeft cleanup contract ​

OnLeft() is the place to unsubscribe events and release resources. It follows five invariants:

  1. Once. Every screen the navigation removes from the active chain and does not put into the KeepAlive cache gets OnLeft() exactly once.
  2. Leaf to root. Same set and order as OnLeaving: a nested child runs before its parent.
  3. In the tree. It runs before the router detaches anything, so panel and parent are live (except after KeepAlive eviction, see 5).
  4. After the last cancellation point. It runs after the guards, BeforeResolve and the BeforeEnter of every screen in the target chain. OnBeforeEnter returning false cancels the navigation at any level of a nested target (root, middle or leaf) and for a screen reused from the KeepAlive cache; OnBeforeRouteUpdate is called on every reused shared-prefix screen of the chain (whether or not its Props changed), and returning false from any of them cancels the navigation. Decide before mutating state: nothing you changed inside the rejecting callback is rolled back. A cancelled navigation never leaves an active screen that was already cleaned up.
  5. KeepAlive. A screen moved into the cache gets no OnLeft() on leaving. It gets it when the cache evicts it (LRU) or on ClearKeepAliveCache, and then it runs outside the tree: the cache holds detached screens.
PathOnLeavingOnLeftTree at OnLeft
Single route → any routeonceoncelive
Nested chain → single route (/a/x → /b)leaf, then rootleaf, then rootlive
Nested chain → another chain (/a/x → /c/z)leaf, then rootleaf, then rootlive
Shared prefix kept (/a/x → /a/y)x onlyx only; /a stayslive
Level with KeepAlive leavesoncenot called; the screen is cachedn/a
Cached screen evicted or cache clearednot called againoncedetached
Parent without KeepAlive leaves while its ChildView caches childrenonce per screencached children (cache cleared first), then the parent: leaf to rootcached children detached, parent live
Navigation cancelled (guard, BeforeResolve, OnBeforeEnter / OnBeforeRouteUpdate returns false at any level of a nested target, including a KeepAlive-reused screen)may have runnot calledn/a

Rules of thumb:

  • Cleanup that depends on the tree (reading or writing ancestors, shell captions, focus) goes in OnLeaving, which always runs with a live tree.
  • Cleanup that does not (unsubscribe, dispose, cancel timers) goes in OnLeft, and must not assume a live parent or panel, because a cached screen is evicted outside the tree.
  • KeepAlive is decided per level of a nested chain. A kept child is cached in its parent's ChildView (outlet) and comes back only while that same parent instance is reused. If the parent is recreated, its cache is empty and the child is built again; if the parent leaves without KeepAlive, its cache is cleared and the cached children receive OnLeft.
  • Previously OnLeft was skipped when a nested chain was left for a single-level route and was lost for a KeepAlive child left for a sibling. Code that relied on it not firing there (double unsubscribe, touching already released state) must now tolerate the call. The order against the next screen also changed: the next screen's BeforeEnter now runs before the old screen's OnLeft.

OnLeaving — last call with a live tree ​

OnLeaving(toRoute) runs once per leave, after every leave and enter guard has passed and before the router detaches any screen. At that point parent, panel and the screen's ancestors — for example the shell that hosts a nested ChildView — are all live. Use it to undo what a screen did to its ancestors:

csharp
// A nested pane writes a caption into its shell on enter
// and clears it while the shell is still reachable.
protected override void OnEntered() =>
    GetFirstAncestorOfType<SettingsScreen>()?.ShowPane("Profile");

protected override void OnLeaving(SusRoute toRoute) =>
    GetFirstAncestorOfType<SettingsScreen>()?.ClearPane();

The AdvancedRouting sample does exactly this (SettingsPaneScreen).

When it is called:

  • For every screen that leaves the active route chain: screens removed from the tree and screens moved into the KeepAlive cache alike.
  • In leaf → root order, so a nested child runs before its parent.
  • Not called for a screen that stays: the shared prefix of a nested chain (/settings/profile → /settings/privacy keeps /settings), or a props update of the same screen instance (OnBeforeRouteUpdate).
  • Not called again when a cached screen is evicted from KeepAlive — it already left the chain.

Keep in mind:

  • OnLeaving runs before OnLeft and always with a live tree; see OnLeft for the other half of the pair.
  • A BeforeResolve guard runs after OnLeaving. If it aborts the navigation, the screen stays active although OnLeaving was already called, and OnEntered is not called again. OnLeft has not run, so the screen is still intact. Still, prefer checks that may cancel a leave in OnBeforeLeave or another guard that runs before OnLeaving, so OnLeaving side effects are never undone halfway.

3. Accessing route data ​

SusRouter merges path parameters (:id) and query (?tab=x) into one Props dictionary. Read via helpers:

csharp
// route "/users/:id",  URL "/users/42?tab=profile"
string id  = GetParam("id");          // "42"      (path param)
string tab = GetQuery("tab");         // "profile" (query)
int    page = GetProp("page", 1);     // typed, with default
object raw = GetProp("payload");      // untyped
MemberTypePurpose
RouterSusRouterscreen owner (navigation, modals)
PropsDictionary<string,object>params + query + passed props (never null)
IsActiveboolwhether the screen is active now
GetProp<T>(key, def)Ttyped read with conversion
GetParam(key, def) / GetQuery(key, def)stringaliases into Props (params/query already merged)

4. Wiring a screen — three ways ​

4.1 Imperative — router.Register(path, type, config?) ​

csharp
var router = new SusRouter();
router.Register("/", typeof(HomeScreen));
router.Register("/about", typeof(AboutScreen));
router.Register("/users/:id", typeof(UserScreen),
    new SusRouteConfig { Name = "user", KeepAlive = true });
router.Mount(root, "/");     // or Mount(uiDocument, "/")

Register returns SusRouteRecord — it can be nested under a parent's Children (see §5, nested).

4.2 Declarative — SusRouteBuilder via SusApp.UseRouter ​

csharp
SusApp.Create(uiDocument)
      .UseTheme(SusTheme.Dark)
      .UseRouter(new SusRouter(), routes => routes
          .Route("/", typeof(HomeScreen)).Name("home")
          .Route<LoginScreen>("/login").Alias("/signin")
          .Route("/users/:id", typeof(UserScreen))
              .Name("user").KeepAlive().Meta("requiresAuth", true)
              .Children(c => c
                  .Route("profile", typeof(ProfileScreen))
                  .Route("posts", typeof(PostsScreen))),
          initialPath: "/")
      .Run();

4.3 Imperative UseRouter (Register inside SusApp) ​

csharp
SusApp.Create(uiDocument)
      .UseRouter(new SusRouter(), r =>
      {
          r.Register("/", typeof(HomeScreen));
          r.Register("/settings", typeof(SettingsScreen));
      }, initialPath: "/")
      .Run();

UseRouter embeds registration + Mount at the correct SusApp finalization point (after token cascade / custom styles, before theme apply).


5. All SusRouteConfig options ​

OptionTypeWhat it does
Namestringname for PushNamed/ReplaceNamed
KeepAlivebooldo not recreate screen on leave (instance cache)
AliasList<string>extra paths that resolve to this route
ChildrenList<SusRouteRecord>nested routes (one level)
Redirectstringon enter — navigate here instead of this route
DefaultPropsDictionary<string,object>default props for the screen
PropsFnFunc<SusRoute, Dictionary<string,object>>props generator from the route (Vue analog props: route => ({...}))
LazyFactoryFunc<SusScreen>lazy screen creation (instead of Activator.CreateInstance)
GuardISusRouteGuardper-route guard CanEnter/CanLeave
BeforeEnterSusRouterGuardfunctional enter guard (after Guard.CanEnter)
TransitionSusRouteTransitiontransition animation (Fade(), SlideLeft(), …)
MetaDictionary<string,object>arbitrary metadata (requiresAuth, title)
CaseSensitiveboolcase-sensitive path matching (default off)
Strictbooltrailing slash matters (/a ≠ /a/)

Examples for each option ​

csharp
// KeepAlive + Transition
router.Register("/feed", typeof(FeedScreen),
    new SusRouteConfig { KeepAlive = true, Transition = SusRouteTransition.Fade() });

// Named + params → PushNamed
router.Register("/users/:id", typeof(UserScreen), new SusRouteConfig { Name = "user" });
router.PushNamed("user", new() { ["id"] = "42" });

// Alias + Redirect
router.Register("/home", typeof(HomeScreen), new SusRouteConfig { Alias = new() { "/start" } });
router.Register("/old", typeof(HomeScreen), new SusRouteConfig { Redirect = "/home" });

// DefaultProps + PropsFn
router.Register("/report", typeof(ReportScreen), new SusRouteConfig
{
    DefaultProps = new() { ["format"] = "pdf" },
    PropsFn = route => new() { ["id"] = route.Params.GetValueOrDefault("id") },
});

// Lazy + Meta + Guard
router.Register("/admin", typeof(AdminScreen), new SusRouteConfig
{
    LazyFactory = () => new AdminScreen(),
    Meta = new() { ["requiresAuth"] = true },
    BeforeEnter = (from, to) => Session.IsAdmin,
});

// Nested (Children) via Register records
var users = router.Register("/users", typeof(UsersScreen), new SusRouteConfig
{
    Children = new()
    {
        router.Register("/users/:id", typeof(UserDetailScreen)),
        router.Register("/users/search", typeof(UserSearchScreen)),
    }
});

6. Navigation (SusRouter methods) ​

MethodPurpose
Push(path, props?)push onto history and navigate
Replace(path, props?)replace current entry
PushNamed(name, pathParams?, props?)navigate by route name
ReplaceNamed(name, pathParams?, props?)replace by name
Back() / Forward()history (cursor-based)
Go(n)offset by n steps
NavigateWithTransition(path, …)navigate with an explicit animation
CanGoBack / CanGoForwardhistory availability (for buttons)
Modal(type, props?) / CloseModal()modals via ModalService

All Push*/Replace*/Back/Forward/Go return NavigationResult (success / guard abort / redirect).

csharp
if (Router.CanGoBack) Router.Back();
Router.Push("/checkout", new() { ["cartId"] = cart.Id });
Router.Modal(typeof(InfoDialog), new() { ["text"] = "Done!" });

7. Nested screens ​

The parent screen registers a child SusRouteView where the router mounts child screens:

csharp
public class UsersScreen : SusScreen
{
    protected override void Build()
    {
        Add(new Label("Users"));

        var childView = new SusRouteView();
        RegisterChildView(childView);   // router mounts /users/:id etc. here
        Add(childView);
    }
}

ChildView (first) and ChildViews (all) are available from code. Chain resolving picks the MatchedChain of records from root to leaf.


8. Entry point (MonoBehaviour) ​

csharp
[RequireComponent(typeof(UIDocument))]
public class AppEntry : MonoBehaviour
{
    [SerializeField] private UIDocument _uiDocument;

    private void OnEnable()
    {
        // Create(UIDocument) applies SusDefault.tss to the panel; prefer it over
        // Create(rootVisualElement), which skips TSS.
        SusApp.Create(_uiDocument)
              .UseTheme(SusTheme.Dark)
              .UseRouter(new SusRouter(), routes => routes
                  .Route("/main-menu", typeof(MainMenuScreen)).Name("menu")
                  .Route("/battle/:matchId", typeof(BattleScreen))
                      .KeepAlive().Transition(SusRouteTransition.Fade()),
                  initialPath: "/main-menu")
              .Run();
    }
}

Integration with SusComponent ​

  • OnEntered() is called before Mounted(). Put logic that needs children not present at Build() time into Mounted().
  • Router/Props are available from OnBeforeEnter(), but not in Created()/Build().
  • Unmounted() — on detach from the panel (after OnLeft()); with KeepAlive the screen is cached off the tree and Unmounted() is not called.