@iyulab/router
v0.24.2
Published
A modern client-side router for web applications with support for Lit and React components
Readme
@iyulab/router
Client-side SPA router for Lit and React with URLPattern matching, nested routes, and route lifecycle events.
Installation
npm install @iyulab/routerlit 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
<!-- 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
rootcontains no<u-outlet>, the router logsRouter 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 #:
const router = new Router({ root: document.body, mode: 'hash', routes });
// /app/#/users/1 — refreshes and deep links work without server configurationRoutes, 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:
// 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.
npx skills add iyulab/node-routernpx skills add ./node_modules/@iyulab/routerRoute Guards
Use enter to run guard logic before rendering.
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(orundefined): continuefalse: cancel navigationstring: 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.
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.paramsandto.metadataare empty;to.navigation.typeispush,replaceortraverse). - 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 firstfalsestops; a guard that throws counts asfalse. 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
beforeunloadfor that.
Route Metadata
Attach metadata to routes using metadata. The router merges metadata from parent to child and exposes it on ctx.metadata.
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.
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):
: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 onu-outlet, andgetBoundingClientRect()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:
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.
<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":
<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):
import { ULink, UOutlet } from '@iyulab/router/react';Route Events
The router dispatches events on window:
route-beginroute-progressroute-doneroute-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.
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:
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.
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:
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.
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
AbortSignalaborted 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 akeyso 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.
