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

@wavelengthusaf/client-router

v0.2.2

Published

Native TypeScript Router

Downloads

1,224

Readme

Wavelength Router

A lightweight, native TypeScript web components router for modern web applications.

Table of Contents


Getting Started

Prerequisites

  • Node.js 24+ and npm
  • A modern browser with native web components support

Installation

Install the package in your application:

npm install @wavelengthusaf/client-router

Use in Your Application

Option 1: npm Package (Published)

Once published to npm, install the package:

npm install @wavelengthusaf/client-router

Then import the elements in your entry point:

// main.ts or main.js
import '@wavelengthusaf/client-router';

Usage

You can implement routing using either the HTML template approach or pure TypeScript.

HTML Template Approach

Use the custom elements directly in your HTML. This is ideal for simpler applications or when you want declarative routing.

Basic Example

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>My App</title>
  </head>
  <body>
    <!-- Router Container - owns navigation and renders matched pages -->
    <wavelength-router>
      <nav aria-label="Main navigation">
        <a href="/">Home</a>
        <a href="/about">About</a>
        <a href="/users/123">User Profile</a>
      </nav>

      <wavelength-route path="/" component="home-page"></wavelength-route>
      <wavelength-route path="/about" component="about-page"></wavelength-route>
      <wavelength-route
        path="/users/:userId"
        component="user-page"
      ></wavelength-route>
      <wavelength-route path="*" component="not-found-page"></wavelength-route>
    </wavelength-router>

    <script type="module">
      import '@wavelengthusaf/client-router';

      // Define your page components as custom elements
      customElements.define(
        'home-page',
        class extends HTMLElement {
          connectedCallback() {
            this.innerHTML = '<h1>Welcome Home</h1>';
          }
        },
      );

      customElements.define(
        'about-page',
        class extends HTMLElement {
          connectedCallback() {
            this.innerHTML = '<h1>About Us</h1><p>We are awesome.</p>';
          }
        },
      );

      customElements.define(
        'user-page',
        class extends HTMLElement {
          connectedCallback() {
            // Access route parameters via routeContext
            const userId = this.routeContext?.routeParams?.userId;
            this.innerHTML = `<h1>User Profile</h1><p>User ID: ${userId}</p>`;
          }
        },
      );

      customElements.define(
        'not-found-page',
        class extends HTMLElement {
          connectedCallback() {
            this.innerHTML = '<h1>404 - Page Not Found</h1>';
          }
        },
      );
    </script>
  </body>
</html>

<wavelength-router> delegates links from its own Light DOM and open Shadow DOM descendants. Keep declarative navigation inside the router, outside the rendered page outlet. Links outside the router intentionally retain native browser navigation; external controls can call router.navigate(path).

How It Works

  1. <wavelength-router> collects route declarations from its Light DOM and open Shadow DOM descendants.
  2. It preserves those declarations and mounts the matching component in a separate internal Light DOM outlet.
  3. Routes are matched in declaration order, so place a wildcard route last.

TypeScript Approach

For more control, you can use the Router class directly and define routes programmatically.

Basic Example

import { Router } from '@wavelengthusaf/client-router';
import type {
  RouteContext,
  RouteDefinition,
} from '@wavelengthusaf/client-router';

const mountElement =
  (tag: string) =>
  (container: HTMLElement, context: RouteContext): void => {
    const page = document.createElement(tag) as HTMLElement & {
      routeContext?: RouteContext;
    };
    page.routeContext = context;
    container.appendChild(page);
  };

const routes: RouteDefinition[] = [
  {
    path: '/',
    routeComponent: {
      mount: mountElement('home-page'),
    },
  },
  {
    path: '/users/:userId',
    meta: {
      title: 'User Profile',
      requiresAuth: true,
      breadcrumbs: [{ label: 'Users', href: '/users' }],
    },
    routeComponent: {
      mount: mountElement('user-page'),
    },
  },
];

const outlet = document.querySelector<HTMLElement>('#app')!;
const router = new Router(routes, {
  outlet,
  hashMode: false, // History API mode; use true for #/path URLs.
  scrollRestoration: 'top', // Use 'preserve' or a callback for custom behavior.
});

router.start();

Use Router directly when the host application owns its outlet, route definitions, and lifecycle. It is also useful in tests because browser globals can be supplied through the optional third constructor argument.

RouteDefinition Structure

Each route requires:

  • path: The URL path pattern (supports :param syntax for dynamic segments)
  • routeComponent: An object with a mount function (and optional unmount)
  • beforeEach: Optional route-specific navigation guard
  • meta: Optional route metadata for titles, analytics, auth requirements, breadcrumbs, or any application-specific data
interface RouteDefinition {
  path: string; // e.g., '/users/:id' or '/products/:category/:id'
  meta?: RouteMeta;
  routeComponent: {
    mount: (
      container: HTMLElement,
      context: RouteContext,
    ) => void | Promise<void>;
    unmount?(): void; // Optional cleanup function
  };
  beforeEach?: RouteGuard;
}

Router Lifecycle

Call start() after the outlet exists. Call destroy() when the host is removed or replaced; it removes navigation listeners and runs the active route's optional unmount() hook.

const router = new Router(routes, { outlet });
router.start();

window.addEventListener('pagehide', () => router.destroy(), { once: true });

Each navigation clears the outlet, runs the prior route's unmount(), and then calls the next route's mount(outlet, context).

mount() may return a promise. The router waits to mark that route complete until the promise resolves. If a newer navigation begins first, the older mount is treated as cancelled: it cannot become active or emit a completion event, and its optional unmount() runs when its promise settles. Route components should use their own AbortController for work that must be stopped before a promise settles.

Direct Router Scoping

By default, a direct Router listens on its injected document. For a nested router or a router inside Shadow DOM, eventTarget and navigationRoot are required: without both, multiple document-level routers can handle the same composed navigation event. Scope them to the containing host:

const host = document.querySelector<HTMLElement>('#account-router')!;
const outlet = host.querySelector<HTMLElement>('[data-outlet]')!;

const router = new Router(routes, {
  outlet,
  eventTarget: host,
  navigationRoot: host,
});
router.start();

Using the same containing element for eventTarget and navigationRoot prevents an outer router from acting on links owned by a nested router. A top-level direct router may omit these options when document-wide navigation is intended.


API Reference

<wavelength-router> Attributes

| Attribute | Type | Description | | -------------------- | --------------------------- | -------------------------------------------------------------------------- | | base-path | string | Base URL pathname stripped before matching and prepended during navigation | | error-component | string | Custom element tag rendered for guard or mount failures | | hash-mode | flag | Enable hash-based routing (#/path) instead of history | | guard | string | Name of a global guard function on window | | focus-strategy | none, outlet, heading | Focus target after navigation; defaults to none | | not-found-component | string | Custom element tag rendered when no route matches | | scroll-restoration | preserve, top | Scroll behavior after navigation; defaults to preserve |

Example with Hash Mode

<wavelength-router hash-mode>
  <wavelength-route path="/" component="home-page"></wavelength-route>
  <wavelength-route path="/profile" component="profile-page"></wavelength-route>
</wavelength-router>

URL becomes: https://example.com/#/profile

Hash-mode query strings are supported. For #/profile?tab=security, the route matches /profile and routeContext.query.get('tab') is security.

Example with a Base Path

Use base-path when the app is served from a subpath. Routes still match their application-relative paths, and programmatic navigation prepends the base path before updating history:

<wavelength-router base-path="/apps/crucible" focus-strategy="heading">
  <wavelength-route path="/" component="home-page"></wavelength-route>
  <wavelength-route path="/simple" component="simple-page"></wavelength-route>
</wavelength-router>
routerElement.navigate('/simple'); // pushes /apps/crucible/simple

For direct Router usage, pass the same value as basePath in the router options.

External route-hash changes are observed automatically. Hash routes use the #/path form, so ordinary fragments such as href="#details" retain native in-page scrolling behavior. Same-origin absolute links ending in #/path are also normalized as hash routes.

<wavelength-route> Attributes

| Attribute | Type | Description | | ----------- | ------ | -------------------------------------------------- | | path | string | URL path pattern (supports :param syntax) | | component | string | Custom element tag name to render for this route | | guard | string | Optional route-specific guard function on window | | meta | JSON | Optional route metadata object |

Changing any route attribute on a connected route automatically rebuilds the router's definitions.

<wavelength-route
  path="/account"
  component="account-page"
  meta='{"title":"Account","requiresAuth":true,"breadcrumbs":[{"label":"Account","href":"/account"}]}'
></wavelength-route>

Example with Error Pages

Use not-found-component for unmatched paths and error-component for guard or mount failures. The rendered component receives the normal routeContext. Not-found contexts include routeContext.meta.status === 404; error contexts include routeContext.meta.status === 500 and routeContext.meta.errorPhase.

<wavelength-router
  error-component="error-page"
  not-found-component="error-page"
>
  <wavelength-route path="/" component="home-page"></wavelength-route>
  <wavelength-route path="/reports" component="reports-page"></wavelength-route>
</wavelength-router>

Path Patterns

  • Static: /about, /dashboard
  • Dynamic: /users/:userId, /posts/:category/:postId
  • Wildcard: * (catches all unmatched routes)

RouteContext

Passed to your component's routeContext property and the mount function:

interface RouteContext {
  path: string; // The matched path, e.g., '/users/123'
  routeParams: {
    // Extracted URL parameters
    [key: string]: string;
  };
  query: URLSearchParams; // Query string parameters
  meta?: RouteMeta; // Metadata from the matched route definition
}

meta is passed through unchanged from the matched RouteDefinition. It is useful for document titles, analytics labels, auth requirements, breadcrumbs, and other application-owned route data.

Accessing RouteContext in Components

class MyPage extends HTMLElement {
  public routeContext?: RouteContext;

  public connectedCallback(): void {
    const ctx = this.routeContext;
    if (ctx) {
      console.log('Current path:', ctx.path);
      console.log('User ID:', ctx.routeParams.userId);
      console.log('Search query:', ctx.query.get('search'));
      console.log('Route title:', ctx.meta?.title);
    }
  }
}

RouteGuard

Navigation guards allow you to intercept and redirect navigation. Define a guard function and reference it via the guard attribute on <wavelength-router> or a specific <wavelength-route>.

// Define guard on window (required for HTML approach)
type NextCallback = (redirect?: string) => void;

(window as unknown as Record<string, unknown>).authGuard = (
  to: RouteContext,
  next: NextCallback,
) => {
  const isAuthenticated = checkAuth(); // Your auth logic

  if (to.path.startsWith('/protected') && !isAuthenticated) {
    next('/login'); // Redirect to login
  } else {
    next(); // Allow navigation
  }
};

(window as unknown as Record<string, unknown>).permissionGuard = (
  to: RouteContext,
  next: NextCallback,
) => {
  const user = getCurrentUser();

  if (!user?.permissions.includes('billing:read')) {
    next('/forbidden');
    return;
  }

  next();
};
<wavelength-router guard="authGuard">
  <wavelength-route path="/" component="home-page"></wavelength-route>
  <wavelength-route
    path="/billing"
    component="billing-page"
    guard="permissionGuard"
  ></wavelength-route>
  <wavelength-route path="/login" component="login-page"></wavelength-route>
  <wavelength-route
    path="/forbidden"
    component="forbidden-page"
  ></wavelength-route>
</wavelength-router>

The router-level guard runs before a matched route's guard. If either guard redirects, later guards and the matched component mount are skipped for that navigation.

Use a route-level guard when only one route or route group needs a specific permission check:

<wavelength-router>
  <wavelength-route path="/" component="home-page"></wavelength-route>
  <wavelength-route
    path="/reports"
    component="reports-page"
    guard="reportsGuard"
  ></wavelength-route>
</wavelength-router>

Guard Flow:

  1. Guard receives RouteContext with destination path
  2. Guard calls next() to allow navigation or next('/redirect-path') to redirect

Guards may call next() asynchronously. If another navigation starts first, the older continuation is ignored so it cannot replace the newer route. If a route's guard attribute changes while connected, <wavelength-router> rebuilds its route definitions automatically.

For a direct Router, pass a global guard as beforeEach, or attach a route-specific guard to an individual route definition:

const routes: RouteDefinition[] = [
  {
    path: '/billing',
    routeComponent: { mount: mountElement('billing-page') },
    beforeEach: permissionGuard,
  },
];

const router = new Router(routes, {
  outlet,
  beforeEach: (context, next) => {
    if (context.path.startsWith('/protected') && !getCurrentUser()) {
      next('/login');
      return;
    }

    next();
  },
});

Route-Level Error Handling

Pass notFoundComponent to a direct Router to render a route component when no route matches. Pass errorComponent to render a route component for guard and mount failures. The not-found component receives routeContext.meta.status === 404. The error component receives routeContext.meta.status === 500 and routeContext.meta.errorPhase.

Pass onError to report route matching, guard, mount, unmount, and scroll failures in one place. The hook receives the thrown value, its phase, and the matched RouteContext when one exists. It works in both Light and Shadow DOM because it is independent of event retargeting.

import type { RouteErrorDetail } from '@wavelengthusaf/client-router';

const router = new Router(routes, {
  outlet,
  errorComponent: {
    mount: (container, routeContext) => {
      const page = document.createElement('error-page');
      page.routeContext = routeContext;
      container.replaceChildren(page);
    },
  },
  onError: ({ error, phase, context }: RouteErrorDetail) => {
    reportRouteError(error, { phase, path: context?.path });
  },
});

The phases are match, guard, mount, unmount, and scroll. Supplying onError handles synchronous guard and mount errors so the router remains started. Unmount and scroll errors are reported and do not prevent the next route from mounting. Without onError or errorComponent, synchronous guard and mount errors retain their normal thrown behavior. wavelength-route-error is dispatched in all cases.

Navigation Lifecycle Events

The router emits composed, bubbling events for each navigation. Listen on the <wavelength-router> itself, an ancestor, or the configured eventTarget of a direct Router. They are useful for loading indicators, telemetry, and error reporting without coupling page components to router internals.

| Event | When it fires | detail | | --------------------------- | -------------------------------------------------------- | ---------------------------------- | | wavelength-route-start | A route is matched, before its guard or component mounts | { path, context } | | wavelength-route-complete | The matched component has mounted successfully | { path, context } | | wavelength-route-error | Route matching, a guard, mount, or unmount fails | { path, context?, error, phase } |

context is the normal RouteContext. A redirect starts a new navigation for its destination and therefore emits completion only for the destination route.

import type { RouteLifecycleEventDetail } from '@wavelengthusaf/client-router';

document.addEventListener('wavelength-route-start', (event: Event) => {
  const { context } = (event as CustomEvent<RouteLifecycleEventDetail>).detail;
  loadingIndicator.show(context?.path);
});

document.addEventListener('wavelength-route-complete', () => {
  loadingIndicator.hide();
});

document.addEventListener('wavelength-route-error', (event: Event) => {
  const { error, path } = (event as CustomEvent<RouteLifecycleEventDetail>)
    .detail;
  reportNavigationError(path, error);
});

Programmatic Navigation

The router provides navigation methods:

import type { RouterElement } from '@wavelengthusaf/client-router';

const routerElement =
  document.querySelector<RouterElement>('wavelength-router')!;

// Navigate to a new path (adds to history)
routerElement.navigate('/about');

// Navigate and replace current entry (no history)
routerElement.replace('/new-page');

// Go back in history
routerElement.back();

// Rescan routes after dynamically attaching an open shadow root
routerElement.refresh();

The same methods are available on a Router instance. Prefer replace() for post-login or canonicalization redirects when the previous URL should not remain in browser history. Programmatic navigation accepts only valid same-origin HTTP(S) paths; external and malformed targets are ignored.

Scroll Restoration

By default, navigation preserves the current scroll position. Set scrollRestoration on a direct Router, or scroll-restoration on <wavelength-router>, to reset to the top after the matched route mounts:

<wavelength-router scroll-restoration="top">
  <wavelength-route path="/" component="home-page"></wavelength-route>
</wavelength-router>

For custom behavior, pass a callback to Router or assign the scrollRestoration property on RouterElement:

const router = new Router(routes, {
  outlet,
  scrollRestoration: (context, navigationType) => {
    if (navigationType === 'pop') return;
    window.scrollTo({ top: 0, left: 0, behavior: 'smooth' });
    analytics.page(context.meta?.title ?? context.path);
  },
});

navigationType is initial, push, replace, or pop. In hash mode, hashchange route navigations are reported as pop.

Route Preloading

Use the application-controlled preload hook to prepare resources for likely next pages. The router calls it for eligible same-origin route links on hover and keyboard focus, and it receives the matched RouteContext. Preloading never delays navigation.

const router = new Router(routes, {
  outlet,
  preload: async (context) => {
    await pageResourceCache.load(context.path, context.query);
  },
});

router.preload('/reports?range=month') starts the same work explicitly. router.getPreloadStatus(path) returns idle, preloading, ready, or failed. Pending and successful paths are deduplicated; a failed preload is retried by the next explicit call, hover, or focus. A preload failure does not affect route navigation or onError handling.

For declarative routing, configure the property before or after connection:

import type { RouterElement } from '@wavelengthusaf/client-router';

const routerElement =
  document.querySelector<RouterElement>('wavelength-router')!;
routerElement.preloader = async (context) => {
  await pageResourceCache.load(context.path, context.query);
};

// Start the same preload work explicitly.
routerElement.preload('/reports?range=month');

// Read the current status: idle, preloading, ready, or failed.
const status = routerElement.getPreloadStatus('/reports?range=month');

The preload machine is intentionally scoped per Router. It retains useful pending work across ordinary navigations; route components should use their own AbortController for resource work that must be cancelled.

Focus Management

Routing removes the previous page from the outlet. Configure focus management when a page change should be announced to keyboard and screen-reader users.

<wavelength-router focus-strategy="heading">
  <wavelength-route path="/" component="home-page"></wavelength-route>
</wavelength-router>

heading focuses the first h1 or level-one ARIA heading in the newly mounted page, adding tabindex="-1" when needed. outlet focuses the render outlet itself. If a Light DOM or open Shadow DOM heading is rendered asynchronously, the router waits up to five seconds before focusing. Both strategies work across Light DOM and open Shadow DOM. For targets that become ready later, dispatch wavelength-focus-ready as the component-owned focus handoff.

An open root attached after navigation cannot be discovered by mutation observation alone. When its focus target is ready, dispatch a composed wavelength-focus-ready event from that target:

heading.dispatchEvent(
  new CustomEvent('wavelength-focus-ready', {
    bubbles: true,
    composed: true,
  }),
);

For a closed root, include the target in the event detail; this is an explicit component-to-router handoff and does not require exposing the root:

heading.dispatchEvent(
  new CustomEvent('wavelength-focus-ready', {
    bubbles: true,
    composed: true,
    detail: { target: heading },
  }),
);

Direct Router users can also supply a callback for application-specific targets:

const router = new Router(routes, {
  outlet,
  focusStrategy: (currentOutlet) =>
    currentOutlet.querySelector<HTMLElement>('[data-route-focus]'),
});

The callback should return a focusable element. Use focusStrategy: 'none' to retain the default behavior.

Closed Shadow DOM is intentionally private. Components that render their focus target asynchronously in a closed root should focus that target themselves when it is ready.

Shadow DOM Navigation

Links inside the router's Light DOM and open Shadow DOM descendants are intercepted automatically. Route declarations may also live in open Shadow DOM descendants of the router. Each nested router owns navigation from its own DOM subtree.

Modified clicks, downloads, and links targeting another browsing context keep their native browser behavior. Same-document fragment links such as href="#details" also retain native scrolling behavior. The router handles only valid same-origin HTTP(S) destinations.

const shell = document.querySelector('app-shell')!;
const shadowRoot = shell.attachShadow({ mode: 'open' });
shadowRoot.innerHTML = '<a href="/settings">Settings</a>';
// The router resolves this link through the event's composed path.

If an open root is attached after the router connects, add its route declarations and then call refresh() so the router can scan and observe it:

import type { RouterElement } from '@wavelengthusaf/client-router';

const router = document.querySelector<RouterElement>('wavelength-router')!;
const routeHost = document.querySelector('route-declarations')!;
const shadowRoot = routeHost.attachShadow({ mode: 'open' });
shadowRoot.innerHTML =
  '<wavelength-route path="/reports" component="reports-page"></wavelength-route>';

router.refresh();

Closed Shadow DOM intentionally hides its internal links from event delegation. Dispatch a composed wavelength-navigate event from the closed-root component instead:

button.addEventListener('click', () => {
  button.dispatchEvent(
    new CustomEvent('wavelength-navigate', {
      bubbles: true,
      cancelable: true,
      composed: true,
      detail: { path: '/account' },
    }),
  );
});

Set detail.replace to true to replace the current history entry. Nested routers handle navigation only for their own DOM subtree.

Closed roots also cannot be scanned for <wavelength-route> declarations. Their owning component can register definitions explicitly, then unregister them when it disconnects:

import { RouteElement, RouterElement } from '@wavelengthusaf/client-router';
import type { RouteDefinition } from '@wavelengthusaf/client-router';

const router = this.closest<RouterElement>('wavelength-router')!;
const definitions: RouteDefinition[] = Array.from(
  this.#closedRoot.querySelectorAll<RouteElement>('wavelength-route'),
).flatMap((route) => {
  const definition = route.toDefinition({ document });
  return definition ? [definition] : [];
});

this.#unregisterRoutes = router.registerRoutes(definitions);

// In disconnectedCallback():
this.#unregisterRoutes?.();

Registered definitions preserve their registration order and are inserted before the first declarative wildcard (path="*"). This keeps a declarative fallback from making closed-root routes unreachable. Registration captures a snapshot; to change closed-root definitions, unregister and register the new array.

Injected Browser Environment

Direct Router instances accept optional window and document dependencies as their third constructor argument. This is useful for an iframe, a test DOM, or an application that needs to avoid implicit global browser access.

const frame = document.querySelector('iframe')!;
const router = new Router(
  routes,
  { outlet },
  {
    window: frame.contentWindow!,
    document: frame.contentDocument!,
  },
);

Matching Utilities

compileRoute() and matchRoute() are exported for applications that need to inspect the same route patterns without mounting a router.

import { compileRoute, matchRoute } from '@wavelengthusaf/client-router';

const compiledRoutes = routes.map(compileRoute);
const match = matchRoute(compiledRoutes, '/users/42');

if (match) {
  console.log(match.routeParams.userId); // "42"
}

Best Practices

1. Provide a Not-Found Route

Use not-found-component when all unmatched paths should render one fallback component:

<wavelength-router not-found-component="not-found-page">
  <wavelength-route path="/" component="home-page"></wavelength-route>
</wavelength-router>

Use a catch-all route when unmatched paths need route-specific guards or metadata:

<wavelength-route path="*" component="not-found-page"></wavelength-route>

2. Access RouteContext Correctly

The routeContext is injected into your custom element. Always check for its existence:

class UserPage extends HTMLElement {
  connectedCallback() {
    const ctx = (this as unknown as { routeContext?: RouteContext })
      .routeContext;
    if (ctx) {
      this.innerHTML = `<p>User: ${ctx.routeParams.userId}</p>`;
    }
  }
}

3. Use Hash Mode for Static Hosts

If deploying to static hosting without server configuration, use hash mode:

<wavelength-router hash-mode> ... </wavelength-router>

This avoids the need for server-side routing rules.

4. Clean Up in Route Components

Implement cleanup logic in the unmount function if needed:

const routes: RouteDefinition[] = [
  {
    path: '/chat',
    routeComponent: {
      mount(container, context) {
        const chat = document.createElement('chat-widget');
        container.appendChild(chat);
      },
      unmount() {
        // Close WebSocket connections, remove event listeners, etc.
      },
    },
  },
];

5. Route Guards for Authentication

Always protect authenticated routes:

(window as unknown as Record<string, unknown>).requireAuth = (ctx, next) => {
  if (isProtectedPath(ctx.path) && !getCurrentUser()) {
    next('/login');
  } else {
    next();
  }
};

6. URL Encoding

Route parameters are automatically URL-decoded. If you pass special characters, they will be properly handled.

7. Order Routes by Specificity

More specific routes should be defined before general ones:

<!-- Correct order -->
<wavelength-route path="/users/:id" component="user-page"></wavelength-route>
<wavelength-route path="/users" component="users-list"></wavelength-route>
<wavelength-route path="*" component="not-found"></wavelength-route>

8. Avoid Memory Leaks

Always clean up when routes unmount:

routeComponent: {
  let interval: number;

  mount(container) {
    const page = document.createElement('my-page');
    container.appendChild(page);

    // Store reference for cleanup
    interval = setInterval(() => updateTime(), 1000);
  },
  unmount() {
    clearInterval(interval);
  },
}

Contributing and Local Development

This section is for contributors working in this repository. Application users only need the package-installation steps above.

Repository Setup

git clone [email protected]:common-components/client-router.git
cd wavelength-router
npm install

Contributors should use Node.js 24 or newer. The GitLab pipeline runs on Node 24 because it is the current LTS baseline for this project; Node 26 is still a Current release. This is documented project policy rather than an install-time constraint, because package.json does not currently declare an engines field.

Husky Setup

Husky runs the repository's tests and lint checks before commits. It runs during npm install; if hooks need to be restored manually, run:

npm run prepare

Development Commands

# Type check
npm run type-check

# Run tests
npm run test

# Run tests with coverage
npm run test -- --coverage

# Run native-browser integration tests (install Chromium once with
# `npx playwright install chromium`)
npm run test:browser

# Lint and format
npm run lint
npm run prettier

# Build for production
npm run build

# Build Storybook
npm run build-storybook

# Run Storybook for component development
npm run storybook

CI/CD Expectations

GitLab CI installs dependencies, builds the package, runs TypeScript checking, linting, unit tests, native-browser integration tests, and security checks. Before opening a merge request, run the same local regression set when feasible:

npm run type-check
npm run lint
npm run test
npm run test:browser
npm run build

Publishing to npm is tag-only. The publish job runs for semantic version tags, verifies the package build, skips versions already present on npm, and requires the NPM_AUTH_TOKEN CI variable.


License

ISC - Wavelength