# Navigation

> The routes the user currently has open, and the verbs that move between them. Read `Navigation.Routes` in a layout to build a tab bar, a breadcrumb, or a back-stack; call `Navigation.Go` and `Navigation.Close` to act on them, or `Navigation.Open` to leave for an external address. The platform supplies the facts and the verbs, and renders none of the chrome.

<!-- id: ui-navigation · area: ui · stability: stable · html: https://osysharp.com/reference/ui/navigation/ -->

## Summary        {#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      {#signature}
```osy syntax
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 show
```

Each 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    {#description}

### `Go` is internal, `Open` is external    {#internal-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:

```osy syntax
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 back
```

The `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"    {#open}
An outlet holds one page at a time unless you ask it to keep more (see [routes and pages](https://osysharp.com/reference/ui/routing/) `#retain`). Either way,
`Navigation.Routes` reports the routes that are **currently open**:

```osy syntax
Outlet;                  // one open route — the page being viewed
Outlet(retain: true);    // every visited route stays open
Outlet(retain: 8);       // …up to 8
```

So 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?   {#reactivity}
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?   {#in-actions}
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:

```osy title="a navigate-away guard" test app=ui-navigation
[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`   {#is-dirty}
`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.

```osy title="save gated on dirty" test app=ui-navigation
[Principal] entity User { string Email; }
entity Organization { [Required] string Name; string Slug; }

[Page("/settings/{slug}")] [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    {#closing}
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:

```osy title="close, then ask" test app=ui-navigation
[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.

```osy title="the three answers to a close prompt" test app=ui-navigation
[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](https://osysharp.com/reference/ui/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`   {#navigating}
`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    {#external}
`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.

```osy title="opening an address outside your app" syntax
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:

```osy title="mint the url, then open it" test app=ui-navigation
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:

```osy title="the address is known at render — use a link" syntax
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?   {#first-load}
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`   {#titles}
`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.

```osy title="a static route name" test app=ui-navigation
[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](https://osysharp.com/reference/ui/on-change/) block. The reaction re-runs as the data changes, so the name follows it:

```osy title="a name that follows the data" test app=ui-navigation
[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`   {#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`:

```osy syntax
string token = Text.Split(Navigation.Hash, "pending_oauth=")[1];
```

### Can I name something `Navigation`? — shadowing   {#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       {#examples}

### Building a tab bar — there is no `Tab` component    {#example-tabs}
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.

```osy title="a tab bar" test app=ui-navigation
[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](https://osysharp.com/reference/ui/component/) for the handler form.

### A breadcrumb, with no retention at all    {#example-breadcrumb}
```osy title="a breadcrumb" test app=ui-navigation
[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 {#see-also}
- [routes and pages](https://osysharp.com/reference/ui/routing/) — route templates, in-app navigation, and `Outlet(retain: …)`.
- [layout primitives](https://osysharp.com/reference/ui/layout/) — declaring the chrome a page renders inside.
- [component](https://osysharp.com/reference/ui/component/) — components, `render` blocks, and actions.
