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
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/BeforeRouteUpdateare called by the router itself — do not override them. Override theprotected virtualOn*hooks (template-method pattern).
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 KeepAliveWith 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:
- Once. Every screen the navigation removes from the active chain and does not put into the KeepAlive cache gets
OnLeft()exactly once. - Leaf to root. Same set and order as
OnLeaving: a nested child runs before its parent. - In the tree. It runs before the router detaches anything, so
panelandparentare live (except after KeepAlive eviction, see 5). - After the last cancellation point. It runs after the guards,
BeforeResolveand theBeforeEnterof every screen in the target chain.OnBeforeEnterreturningfalsecancels the navigation at any level of a nested target (root, middle or leaf) and for a screen reused from the KeepAlive cache;OnBeforeRouteUpdateis called on every reused shared-prefix screen of the chain (whether or not itsPropschanged), and returningfalsefrom 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. - KeepAlive. A screen moved into the cache gets no
OnLeft()on leaving. It gets it when the cache evicts it (LRU) or onClearKeepAliveCache, and then it runs outside the tree: the cache holds detached screens.
| Path | OnLeaving | OnLeft | Tree at OnLeft |
|---|---|---|---|
| Single route → any route | once | once | live |
Nested chain → single route (/a/x → /b) | leaf, then root | leaf, then root | live |
Nested chain → another chain (/a/x → /c/z) | leaf, then root | leaf, then root | live |
Shared prefix kept (/a/x → /a/y) | x only | x only; /a stays | live |
Level with KeepAlive leaves | once | not called; the screen is cached | n/a |
| Cached screen evicted or cache cleared | not called again | once | detached |
Parent without KeepAlive leaves while its ChildView caches children | once per screen | cached children (cache cleared first), then the parent: leaf to root | cached 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 run | not called | n/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 liveparentorpanel, because a cached screen is evicted outside the tree. KeepAliveis decided per level of a nested chain. A kept child is cached in its parent'sChildView(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 withoutKeepAlive, its cache is cleared and the cached children receiveOnLeft.- Previously
OnLeftwas 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'sBeforeEnternow runs before the old screen'sOnLeft.
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:
// 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/privacykeeps/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:
OnLeavingruns beforeOnLeftand always with a live tree; seeOnLeftfor the other half of the pair.- A
BeforeResolveguard runs afterOnLeaving. If it aborts the navigation, the screen stays active althoughOnLeavingwas already called, andOnEnteredis not called again.OnLefthas not run, so the screen is still intact. Still, prefer checks that may cancel a leave inOnBeforeLeaveor another guard that runs beforeOnLeaving, soOnLeavingside 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:
// 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| Member | Type | Purpose |
|---|---|---|
Router | SusRouter | screen owner (navigation, modals) |
Props | Dictionary<string,object> | params + query + passed props (never null) |
IsActive | bool | whether the screen is active now |
GetProp<T>(key, def) | T | typed read with conversion |
GetParam(key, def) / GetQuery(key, def) | string | aliases into Props (params/query already merged) |
4. Wiring a screen — three ways
4.1 Imperative — router.Register(path, type, config?)
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
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)
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
| Option | Type | What it does |
|---|---|---|
Name | string | name for PushNamed/ReplaceNamed |
KeepAlive | bool | do not recreate screen on leave (instance cache) |
Alias | List<string> | extra paths that resolve to this route |
Children | List<SusRouteRecord> | nested routes (one level) |
Redirect | string | on enter — navigate here instead of this route |
DefaultProps | Dictionary<string,object> | default props for the screen |
PropsFn | Func<SusRoute, Dictionary<string,object>> | props generator from the route (Vue analog props: route => ({...})) |
LazyFactory | Func<SusScreen> | lazy screen creation (instead of Activator.CreateInstance) |
Guard | ISusRouteGuard | per-route guard CanEnter/CanLeave |
BeforeEnter | SusRouterGuard | functional enter guard (after Guard.CanEnter) |
Transition | SusRouteTransition | transition animation (Fade(), SlideLeft(), …) |
Meta | Dictionary<string,object> | arbitrary metadata (requiresAuth, title) |
CaseSensitive | bool | case-sensitive path matching (default off) |
Strict | bool | trailing slash matters (/a ≠ /a/) |
Examples for each option
// 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)
| Method | Purpose |
|---|---|
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 / CanGoForward | history availability (for buttons) |
Modal(type, props?) / CloseModal() | modals via ModalService |
All Push*/Replace*/Back/Forward/Go return NavigationResult (success / guard abort / redirect).
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:
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)
[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 beforeMounted(). Put logic that needs children not present atBuild()time intoMounted().Router/Propsare available fromOnBeforeEnter(), but not inCreated()/Build().Unmounted()— on detach from the panel (afterOnLeft()); with KeepAlive the screen is cached off the tree andUnmounted()is not called.
Related docs
- 02-router-api.md — full
SusRouterAPI - 04-routeview.md —
SusRouteViewand KeepAlive - 05-modals.md — modals
- 06-guards-transitions.md — guard pipeline and animations