npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/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

<!-- 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 #:

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:

// 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-router
npx skills add ./node_modules/@iyulab/router

Route 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 (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.

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.

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 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:

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-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.

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 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.