Summary#
Navigation is available in every component. It tells you which routes the user has open, which one they are
looking at, and which ones hold unsaved edits — and it lets you open and close them.
It exists so you can build the chrome that names and switches between pages: a tab bar, a breadcrumb, a mobile back-stack, a page header. The platform ships none of that chrome. It ships the facts.
Signature#
Navigation.Routes // the open routes, in the order they were opened
Navigation.CurrentPath // the address of the active route, or null before the first page mounts
Navigation.Hash // the URL fragment, decoded and without the leading `#` (empty when there is none)
Navigation.IsDirty // does the CURRENT page hold uncommitted edits? (a bool)
Navigation.Go(path) // navigate to a route OF THIS APP — checked against the pages it declares
Navigation.Open(url) // open an EXTERNAL address in a new tab
Navigation.Open(url, sameTab: true) // …or leave the app in THIS tab, replacing the page
Navigation.Close(path) // close an open route; false if it has unsaved edits
Navigation.Close(path, force) // close it regardless
Navigation.Save(path) // commit that route's own edits, then close it
Navigation.SetTitle(title) // name THIS route — what its tab and the browser title showEach entry in Navigation.Routes has four fields:
| Field | Type | Meaning |
|---|---|---|
Path | string | The route's address (/users, /apps/3f2a) — what you pass back to Go and Close. |
Title | string | The page's name: whatever it last passed to Navigation.SetTitle, else its static [Title("…")], else null. |
IsActive | bool | This is the route the user is looking at. Exactly one open route is active. |
IsDirty | bool | The page has uncommitted edits — closing it would discard them. |
Description#
Go is internal, Open is external#
The two navigation verbs split on one question: is the address a page of this app?
Go takes a route this app declares, so its literal argument is checked at compile time against the app's own
[Page("…")] declarations — a typo names the routes that do exist instead of navigating to nothing. A route built at
runtime ("/org/" + slug) is not checked, because there is nothing to check it against.
Open takes an address that is not a page of this app — an external site, a download link, a server endpoint —
and is never checked against the app's routes. By default it opens beside your work, in a new tab. Pass
sameTab: true to leave the app in the current tab instead:
Navigation.Go("/settings"); // a page of this app
Navigation.Open("https://docs.example.com"); // a new tab, beside the app
Navigation.Open("/api/oauth/authorize?provider=Google", sameTab: true); // leave, and come backThe sameTab case is for a redirect you return from. An OAuth authorize endpoint sends the visitor to the
provider and brings them back to the app; a new tab cannot serve that, because the original tab sits there
unchanged — still logged out. A download or a reference page wants the default: it opens beside the work in
progress rather than replacing it.
Both forms of Open sanitize the address against the same scheme allow-list an href uses, so a javascript:
address is refused however it reached the call.
What counts as "open"#
An outlet holds one page at a time unless you ask it to keep more (see routes and pages #retain). Either way,
Navigation.Routes reports the routes that are currently open:
Outlet; // one open route — the page being viewed
Outlet(retain: true); // every visited route stays open
Outlet(retain: 8); // …up to 8So a plain shop or marketing layout — which never opts into retention — can still read the current page's title for a header or a breadcrumb. It just always sees exactly one route.
Does my chrome re-render when a route changes?#
Reading Navigation in a component's render subscribes that component to it. When a route opens, closes, becomes
active, or its page becomes dirty, the component re-renders. An unsaved-work dot next to a tab stays truthful without
you polling anything.
A component that never mentions Navigation subscribes to nothing and costs nothing.
Can I read Navigation inside an action?#
An action reads Navigation the same way render does — where you are and whether the page is dirty are simply
available, so a guard can ask for itself instead of being told:
[Page("/guarded")] [Render(CSR)]
component Guarded() {
string blockedPath = "";
action Leave(string path) {
if (path != Navigation.CurrentPath && Navigation.IsDirty) { blockedPath = path; }
else { Navigation.Go(path); }
}
render {
Pressable(onClick: () => Leave("/users")) { Text("Users"); }
if (blockedPath != "") { Text("Unsaved changes"); }
}
}The action sees the router as it is when the action runs — not as it was when the screen was drawn. That is the difference that matters: a value captured at render time and carried into a handler is a snapshot, and a stale one is exactly what a navigate-away guard must not act on. Only the target has to travel.
Is there anything to save? — Navigation.IsDirty#
Navigation.IsDirty is a bool: does the current page hold uncommitted edits? It is the active route's own
IsDirty, read directly — the common case where a page gates its own Save on whether there's anything to save.
Like the per-route field it reflects, it updates itself: bind a Save button's disabled to it and the button lights up
the moment the user changes something and dims again after a successful save.
[Principal] entity User { string Email; }
entity Organization { [Required] string Name; string Slug; }
[Page("/settings")] [Render(CSR)]
component Settings(string slug) {
var org = Organization.Single(o => o.Slug == slug);
action Save() { UnitOfWork.Commit(); }
live var canSave = Navigation.IsDirty && Validation.Violations.Count == 0; // something to save, and it's valid
render {
Pressable(onClick: Save, disabled: !canSave) { Text("Save"); }
Input(value: org.Name);
}
}It is false on a freshly-served page (SSR) and until the first edit.
Closing is where unsaved work is protected#
Switching between open routes never discards anything: the page you leave stays mounted, with its state and its
half-typed form intact. Closing is the discard point, so Close is deliberately awkward about it:
[Page("/tabs")] [Render(CSR)]
component TabCloser() {
string discarding = "";
action CloseTab(string path) {
// The page has unsaved edits. Ask, in your own words, in your own dialog.
if (!Navigation.Close(path)) { discarding = path; }
}
action ConfirmDiscard() { Navigation.Close(discarding, true); discarding = ""; }
render {
Pressable(onClick: () => CloseTab("/users")) { Text("Close"); }
if (discarding != "") { Pressable(onClick: ConfirmDiscard) { Text("Discard"); } }
}
}Navigation.Close(path) returns false — and changes nothing — when the page has uncommitted edits. Passing
force closes it anyway. The platform never shows a confirmation dialog, because it has no business deciding what
your app's dialogs look like or what they say.
Close also returns false for a path that isn't open, and for every path when your outlet retains nothing: a
single open route has nothing to fall back to, so there is nothing a close could reveal.
Navigation.Save(path) is the other answer to the close prompt: it commits that route's own unsaved edits,
then closes it. A page's edits live in the page's own unit of work, which your chrome can't reach — but the router can,
so Save is how a "Save before closing?" prompt keeps the work instead of discarding it. So a close prompt has three
natural answers — Save (Navigation.Save(path)), Discard (Navigation.Close(path, true)), and Keep editing
(dismiss your prompt).
A save can be refused, and Navigation.Save tells you so: the values may break the entity's own rules, or the
server may say no. It raises, so catch it and say why — the route stays open with the edits intact, and the user can
fix them. A prompt that closed itself while the save failed would look exactly like a save that worked, which is the one
thing it must never do.
[Page("/save-prompt")] [Render(CSR)]
component SavePrompt() {
string closing = "/users";
string saveError = "";
action SaveAndClose() {
try { Navigation.Save(closing); closing = ""; }
catch (ValidationException ex) { saveError = ex.Message; } // your prompt says why; the route stays open
}
action Discard() { Navigation.Close(closing, true); } // throw them away, then close
action KeepEditing() { closing = ""; } // just dismiss the prompt
render {
Pressable(onClick: SaveAndClose) { Text("Save"); }
Pressable(onClick: Discard) { Text("Discard"); }
Pressable(onClick: KeepEditing) { Text("Keep editing"); }
Text(saveError);
}
}See Validation for what a refusal carries — ex.Violations names each field and the rule it broke, so a form can
put the message beside the control that produced it.
Moving to another page — Navigation.Go#
Navigation.Go(path) moves to a route exactly as clicking an in-app link would: the address bar updates, Back and
Forward work, and no page reload happens. It returns immediately — the destination's data loads on its own — so an
action that navigates does not wait for the next page.
Leaving the app — an external url#
Navigation.Go moves within your app. Navigation.Open(url) leaves it: it opens an external address in a new
tab, so the page the user is on — and anything they have typed into it — stays exactly where it was.
Navigation.Open("https://docs.example.com/getting-started");Reach for it when the address cannot be known while the page renders, which is the case a plain link cannot cover. The usual example is a download: a file's link is minted on demand, is signed, and expires shortly after, so there is nothing to put in a link until the moment someone asks for it. Mint it in the action, then open it:
entity Document {
[Required] string Name;
security { allow create, read, update when IsAuthenticated; }
}
string GetDownloadUrl(Guid fileId) { // signed and short-lived, so it cannot be a link
return "https://files.example.com/" + fileId.ToString();
}
[Page("/files")] [Render(CSR)]
component Files() {
var docs = Document.OrderBy(d => d.Name).ToList();
action Download(Guid fileId) {
string url = GetDownloadUrl(fileId); // minted now, valid for a few minutes
Navigation.Open(url);
}
render {
foreach (var d in docs) {
Pressable(onClick: () => Download(d.Id)) { Text(d.Name); }
}
}
}When the address is known as the page renders, prefer an ordinary link — it is a real link, so the browser can offer "open in new tab", copy it, and show where it goes:
Link(href: "https://example.com") { Text("Example"); }Only ordinary web addresses open — https:, http:, mailto: and tel:. Anything else is refused rather than
opened, so a url that arrived from your data can never be turned into something executable. A refused url is
reported in the browser console, so a link that does not open tells you why instead of failing silently.
What is open on a cold load, and after a refresh?#
When a browser first lands on one of your pages from the outside — a bookmark, a shared link, a brand-new tab —
exactly one route is open: the one being served. Navigation.Routes reflects that on the server-rendered first paint
too, so your chrome paints with the page instead of appearing a moment later.
A refresh of a running session is different. A retaining outlet (Outlet(retain: …)) remembers the tabs you had
open, so a reload re-opens all of them — the served route stays active and the rest come back alongside it, in the
same order — instead of collapsing to the single page the browser happened to reload. The tab set is what
survives; a tab's unsaved edits are not (a reload discards the in-memory overlay, and the browser warns you first).
A plain Outlet; keeps nothing, so it always lands on exactly the one served route.
What is a tab called? — Title and SetTitle#
Title comes from the page's [Title("…")]. A page that declares none reports null, and it is up to your chrome to
decide what to show — its path, a fallback label, or nothing.
[Page("/users")] [Title("Users")] [Layout(AppShell)] [Render(CSR)]
component UsersPage() {
render { Text("Users"); }
}When the name depends on the page's data — an editor titled by the record it's editing — call
Navigation.SetTitle(title) from an on change block. The reaction re-runs as the data changes, so the name follows it:
[Page("/orgs/{slug}")] [Render(CSR)]
component OrgEdit(string slug) {
var org = Organization.Single(o => o.Slug == slug);
on change { Navigation.SetTitle(org.Name); } // the tab + the browser title track the org's name
render { Input(value: org.Name); }
}SetTitle names the route the calling page is mounted in. It feeds Navigation.Routes[].Title — so whatever chrome
you render from that (a tab strip, a breadcrumb) follows along — and it drives the browser title of the active
route. [Title("…")] remains the fallback: what's shown server-rendered, and before the reaction first runs. Setting
the same title twice does nothing, so re-running the on change block costs nothing.
Reading what came after the # — Navigation.Hash#
Navigation.Hash is everything after the # in the current address, URL-decoded and without the leading # — so at
/oauth/complete#pending_oauth=abc it reads pending_oauth=abc. It is empty when there is no fragment, and empty
server-side, where there is no location at all.
It exists for the one case a query string cannot cover: a fragment is never sent to the server, so it is the right
carrier for a one-time bearer value handed back to a page by an external redirect. Pull a value out of it with
Text.Split:
string token = Text.Split(Navigation.Hash, "pending_oauth=")[1];Can I name something Navigation? — shadowing#
Navigation is an ambient name, not a keyword. A parameter, state member, or query named Navigation shadows it,
exactly as a local variable shadows any other ambient. Nothing is reserved.
Examples#
Building a tab bar — there is no Tab component#
Everything below is ordinary Osy#. There is no Tab component in the platform, and this is the whole reason:
you write the tab bar you actually want.
[Layout]
component AppShell() {
string discarding = "";
action Close(string path) {
if (!Navigation.Close(path)) { discarding = path; } // your own confirm, your own state
}
action ConfirmDiscard() { Navigation.Close(discarding, true); discarding = ""; }
render {
Row(gap: 1) {
foreach (var r in Navigation.Routes) {
Row(gap: 1) {
Link(href: r.Path) { Text(r.Title); } // navigating is what an anchor is for
if (r.IsDirty) { Text("•"); }
Pressable(onClick: () => Close(r.Path)) { Text("×"); }
}
}
}
if (discarding != "") { Pressable(onClick: ConfirmDiscard) { Text("Discard"); } }
Outlet(retain: 8);
}
}Each × closes its own tab: () => Close(r.Path) binds the argument in the loop, so every row's handler carries
that row's path. See component for the handler form.
A breadcrumb, with no retention at all#
[Layout]
component ShopShell() {
render {
Row(gap: 1) {
Text("Shop");
foreach (var r in Navigation.Routes) { Text(r.Title); } // exactly one: the page being viewed
}
Outlet;
}
}See also#
- routes and pages — route templates, in-app navigation, and
Outlet(retain: …). - layout primitives — declaring the chrome a page renders inside.
- component — components,
renderblocks, and actions.