# @iyulab/router

Client-side SPA router for Lit and React with URLPattern matching, nested routes, and route lifecycle events.

## Installation

```bash
npm install @iyulab/router
```

`lit` is an optional peer: install it (`npm install lit`) when routes return Lit templates. Routes
that return elements or React content need neither Lit nor anything else beyond the router.

**Load one copy.** The router keeps module state — the running router, selection channels, the `<u-link>` and
`<u-outlet>` definitions — so an app and the packages it builds on (such as `@iyulab/modern-app`, which takes the
router as a peer) must share one copy. After installing or upgrading, `npm ls @iyulab/router` should list a single
version. A second copy that loads stops with an error saying `<u-link>` is already defined by another copy. A
package that declares the router as a broad peer (`"*"`) can keep an older copy satisfied after an upgrade without
any install error; remove the older entries from the lockfile and `node_modules`, then install again.

## Quick Start

```html
<!-- The router renders matched output into the <u-outlet> inside `root`. -->
<div id="app"><u-outlet></u-outlet></div>

<script type="module">
  import { Router } from '@iyulab/router';
  import { html } from 'lit';

  const router = new Router({
    root: document.getElementById('app'),
    basepath: '/',
    routes: [
      { index: true, render: () => html`<home-page></home-page>` },
      { path: '/users/:id', render: (ctx) => html`<user-page .id=${ctx.params.id}></user-page>` },
    ],
    fallback: {
      render: (ctx) => html`<error-page .error=${ctx.error}></error-page>`,
    },
  });

  router.go('/users/1');
</script>
```

> If `root` contains no `<u-outlet>`, the router logs
> `Router initialization failed: Timed out waiting for <u-outlet>` and renders nothing.

## Hash Mode

On a static host that serves one document (no server-side fallback for every path), put the route
after `#`:

```typescript
const router = new Router({ root: document.body, mode: 'hash', routes });
// /app/#/users/1 — refreshes and deep links work without server configuration
```

Routes, `basepath` and `router.go()` apply to the part after `#`, and `<u-link>` renders `#/…`
hrefs. A fragment inside a route (`#section`) is not available in this mode.

## React + Vite

The outlet renders React content by dynamically importing `react-dom/client` when a route
returns a React element. Under Vite's dev pre-bundling (`optimizeDeps`), this CommonJS
interop can break — `TypeError: createRoot is not a function` by default, or
`ReferenceError: module is not defined` if only the router is excluded. Pre-bundle
`react-dom/client` while leaving the router itself unbundled:

```ts
// vite.config.ts
import { defineConfig } from 'vite';

export default defineConfig({
  optimizeDeps: {
    exclude: ['@iyulab/router'],
    include: ['react-dom/client'],
  },
});
```

## Skills Usage

Install the `iyulab-router` skill for agent-friendly package guidance.

```bash
npx skills add iyulab/node-router
```

```bash
npx skills add ./node_modules/@iyulab/router
```

## Route Guards

Use `enter` to run guard logic before rendering.

```typescript
const router = new Router({
  root: document.body,
  enter: (ctx) => {
    if (!isAuthenticated() && ctx.pathname !== '/login') return '/login';
    return true;
  },
  routes: [
    { path: '/login', render: () => html`<login-page></login-page>` },
    {
      path: '/admin',
      enter: () => hasRole('admin') || '/forbidden',
      render: () => html`<admin-page></admin-page>`,
    },
  ],
});
```

`enter` return values:
- `true` (or `undefined`): continue
- `false`: cancel navigation
- `string`: redirect to that path

### Leaving a screen

`leave` is the other side of `enter`: it runs before the current screen is replaced, so a screen with unsaved
input can ask first. Return `false` to stay — the screen and the address do not change.

```typescript
import { Router } from '@iyulab/router';

let dirty = false; // the screen's own state — true while an edit is unsaved

new Router({
  root: document.body,
  routes: [
    {
      path: '/orders/:id',
      leave: () => !dirty || confirm('Discard your changes?'),
      render: (ctx) => Object.assign(document.createElement('p'), { textContent: `Order ${ctx.params.id}` }),
    },
  ],
});
```

- It receives `{ from, to }`: the current screen's context and the destination (address only — `to.params` and
  `to.metadata` are empty; `to.navigation.type` is `push`, `replace` or `traverse`).
- It runs for every navigation that changes the path or query — links, `go()`, `navigate()`, a selection channel
  that goes through the router, Back and Forward. A hash-only change, redirect hops and the first route do not ask.
- Route guards of the current screen run child first, then the global `RouterConfig.leave`. The first `false` stops;
  a guard that throws counts as `false`. A guard may return a promise (an in-app dialog).
- A guard that answers right away (a boolean) does not delay anything — the navigation goes on in the same tick, as
  without guards. Only a guard that returns a promise is waited for.
- Back and Forward have no «cancel» in the History API. When a guard does not let a Back or Forward through right
  away, the router returns to the entry you were on, waits for the answer, and goes the same number of entries again
  if the guard allows it.
- Leaving the document (reload, another site, closing the tab) is not a navigation the router sees — use the
  browser's `beforeunload` for that.

## Route Metadata

Attach metadata to routes using `metadata`. The router merges metadata from parent to child and exposes it on `ctx.metadata`.

```typescript
const routes = [
  {
    path: '/dashboard',
    metadata: { requiresAuth: true, section: 'dashboard' },
    render: () => html`<dashboard-layout><u-outlet></u-outlet></dashboard-layout>`,
    children: [
      {
        path: 'settings',
        metadata: { tab: 'settings' },
        render: (ctx) => html`<settings-page .metadata=${ctx.metadata}></settings-page>`,
      },
    ],
  },
];
```

## Nested Routes

Parent routes must render `<u-outlet>` to host child route content.

```typescript
const routes = [
  {
    path: '/nested',
    render: () => html`<nested-layout><u-outlet></u-outlet></nested-layout>`,
    children: [
      { index: true, render: () => html`<nested-home></nested-home>` },
      { path: 'lit', render: () => html`<nested-lit></nested-lit>` },
      { path: 'react', render: () => <NestedReact /> },
    ],
  },
];
```

## Link and Outlet Components

- `<u-link>`: SPA-aware anchor element
- `<u-outlet>`: render target for matched route output

## Outlet Layout

`<u-outlet>` has **no box of its own**: it is a mount point, and a route's screen lays out as
if it were a direct child of the element that contains the outlet. The rule is adopted on
whichever tree the outlet is connected to (the document, or the shadow root if it lives in one):

```css
:where(u-outlet) { display: contents; }
```

That is what makes all three kinds of screen behave in a sized, scrolling container (an app
shell's content area) — each measured in a real engine:

| Screen | What it needs | With `contents` |
|---|---|---|
| **Fills the area** — `height: 100%`, a layout that stretches to the viewport | the parent's height, passed down | resolves against the parent directly |
| **Fills the area with more content than fits** — a table with `flex: 1; min-height: 0` holding more rows than the viewport | the parent's height, **not** its own content height | the table scrolls inside itself |
| **Flows** — a form or document taller than the area | to grow, with the container scrolling | the container scrolls, and its end padding stays at the end of the scroll |

Any box in between breaks one of them. A box with `height: 100%` pins itself to the parent, so
a flowing screen overflows it and the scroll container's end padding — added after its in-flow
children, not after what they overflow with — drops out: scrolled to the bottom, the content
sits flush on the edge. A grid box with `min-height: 100%` grows with a flowing screen, but a
grid track is sized from its item's content, and during that sizing the item's `height: 100%`
counts as `auto` — so a filling screen with many rows grows the track to every row and the
table never scrolls. A block or flex box with `min-height: 100%` leaves a descendant's
`height: 100%` with nothing definite to resolve against.

Consequences of having no box:

- CSS that needs a box — `padding`, `background`, `border` — does nothing on `u-outlet`, and
  `getBoundingClientRect()` on it returns zeros. Style the element that contains the outlet,
  or the screen itself.
- In print media there is no box to trap the bottom margin of a screen's last block, so it
  collapses to the end of the document and no trailing blank page appears.

### Overriding it

The `:where()` wrapper makes the rule's specificity zero, so **any** `u-outlet { … }` rule your
application writes wins, regardless of sheet order and without `!important`:

```css
u-outlet { display: block; }                   /* give the outlet a box again */
u-outlet { display: block; height: 100%; }     /* the 0.14.0 behavior */
u-outlet { display: grid; grid-template-columns: minmax(0, 1fr); min-height: 100%; }  /* the 0.15.x behavior */
```


`<u-link>` supports `href`, `target`, `rel`, and `navigate`.

```html
<u-link href="/docs">Docs</u-link>
<u-link href="https://example.com" target="_blank" rel="noopener noreferrer">External</u-link>
```

### Linking to a path the router does not own

Not every path on your origin is a SPA route. A static docs site, a server-rendered
page, a report endpoint, an auth redirect — these live alongside your routes and must
open as documents, in the same tab. Declare that with `navigate="document"`:

```html
<u-link href="/help/" navigate="document">Help</u-link>
```

The router leaves the click alone and the browser navigates normally. The same works
on a plain anchor via `data-navigate="document"`, which is what the element renders.

`navigate` defaults to `router`: same-origin links are handled as SPA navigation, as
before. Note that `navigate` asks a different question than the automatic origin check
— not *"is this another origin?"* but *"is this another document?"* Without it the only
escapes were a different origin, `target="_blank"` (which forces a new tab), or a `#`
fragment (which is not another document at all).

React wrappers (they need `react` and `@lit/react`, both optional peers — `npm install react @lit/react`):

```tsx
import { ULink, UOutlet } from '@iyulab/router/react';
```

## Route Events

The router dispatches events on `window`:

- `route-begin`
- `route-progress`
- `route-done`
- `route-error`

`route-error` fires for every failure; the console logs only the unexpected ones (a failed load or render).
A 4xx failure — no route matched, a guard's refusal — is an outcome the fallback draws, not an error.

```typescript
window.addEventListener('route-progress', (e) => {
  console.log(e.progress);
});
```

### Reading the current route without the router

A widget that follows `route-begin` — a menu marking the current item — may be connected *after* the first
navigation, when it is rendered by a route (a shell as a nested route's parent). It has missed that event.
Start from `currentRoutePath()`, which reads the current location in the router's mode, in the same form as
`RouteContext.path`:

```typescript
import { currentRoutePath } from '@iyulab/router';

let path = currentRoutePath();                                  // e.g. '/orders/7?tab=log'
window.addEventListener('route-begin', (e) => { path = e.context.path; });
```

### Navigating without the router

Code that does not hold the router asks for a navigation with `navigate(href, options)`. The running router
performs it; with no router it becomes a document navigation. `<u-link>` uses the same request — it no longer
pushes an entry and dispatches a synthetic `popstate`, so a `popstate` your app hears is a real Back or Forward.

```typescript
import { navigate } from '@iyulab/router';

navigate('/login', { replace: true });
```

### How a screen was reached

`context.navigation` is `{ type, entryKey }`: `type` is `push`, `replace`, `reload`, or `traverse` (Back/Forward
returned to an existing entry — the platform Navigation API's vocabulary), and `entryKey` names the history entry,
the same when you return to it. State given to `go(href, { state })` survives Back, Forward and a reload.

### Scroll restoration

With `scrollRestoration`, Back, Forward and a reload return to where you were reading; a new navigation starts at
the top (or the `#hash` element), and `replace` leaves the position alone. A screen that grows after rendering — data
arriving later — is waited for until it reaches the saved position; scrolling first cancels the restore. When an
inner box scrolls rather than the document, name it:

```typescript
new Router({ root, routes, scrollRestoration: { container: () => shell.querySelector('main') } });
```

### Selection channels — several views following one selection

A route says *which screen*; a selection says *what you are looking at inside it*. A named channel holds one current
value that a list, a side panel and an open tab all follow — same name, same channel, across modules that do not
know each other. With `query`, the address is the source: the value survives a reload, a shared link and Back.

```typescript
import { selection } from '@iyulab/router';

const order = selection<string>('order', { query: 'order' });            // ?order=G-7
order.subscribe((id, signal) => { if (id) load(id, { signal }); });      // a later pick aborts this one
order.set('G-7');
```

- **Latest wins** — each value comes with an `AbortSignal` aborted by the next pick; pass it to the fetch so a slow
  answer for an earlier pick never overwrites a later one.
- **History** — `history: 'replace'` (default) keeps picks out of the history; `'push'` makes each pick an entry, so
  Back returns to the previous pick. Give the route a `key` so a query-only change does not remount the screen.
- **Pin a view** — `channel.follow(fn)` returns `{ pin, unpin }`: a pinned view stays where it is; unpinning catches up.
- **Adapters** — Lit: `new SelectionController(this, channel)` (`value`, `signal`, `pin()`); React:
  `const [value, set] = useSelection(channel)` from `@iyulab/router/react`.

## License

MIT License. See [LICENSE](LICENSE).
