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

@wincc-oa/wui-router

v2.0.1

Published

WinCC Open Architecture Dashboard - routing library based on @lit-labs/router.

Readme

wui-router

Routing library for WinCC OA WebUI, based on a fork of @lit-labs/router v0.1.4 (BSD-3-Clause).

Features

  • URLPattern matching via urlpattern-polyfill
  • Hash-based routing (index.html#/path)
  • Route guards — sequential async checks before navigation
  • Named outlets — render route content into multiple layout regions
  • Nested routing — parent layouts with child outlets
  • Lifecycle hooksonBeforeEnter, onBeforeLeave, onAfterEnter, onAfterLeave
  • Async children — lazy-load child routes via () => Promise<WuiRouteConfig[]>
  • Route actionsaction(context, commands) with component(), redirect(), prevent()
  • Remount-per-navigation by default, with per-route opt-in reuse: true
  • $.<name> URL prefix grammar for multi-outlet bindings (bare keys are main-route search)

URL grammar

A URL for the router looks like #/<mainPath>?<query>, where <query> follows this grammar:

| Form | Meaning | | ------------------------ | ----------------------------------------------------------- | | foo=bar | Main-route search param (mainSearch.foo = 'bar') — always | | $.detail=/dashboard/42 | Bind outlet named detail to path /dashboard/42 | | $.detail.tab=graph | Outlet-scoped search param for outlet detail |

Bare query keys are always main-route search — they are never treated as outlet bindings. The $. prefix is the only way to bind or address a named outlet from a URL. Example combining all three:

#/dashboard/100/add?filename=label.json&x=10&y=20&$.detail=/preview&$.detail.mode=live

No allowlist or reserved-set is required for app-specific keys.

Route Configuration

import type { WuiRouteConfig } from '@wincc-oa/wui-router/core/route-config.js';

const routes: WuiRouteConfig[] = [
  {
    path: '/dashboard',
    component: 'my-dashboard',
    routeId: 'dashboard'
  },
  {
    path: '/settings',
    component: 'my-settings',
    routeId: 'settings',
    guards: [authGuard]
  },
  {
    // Opt out of remount when a route benefits from a persistent element
    // (e.g. keeps expensive state that would be lost on re-mount).
    path: '/rich-editor/:id',
    component: 'rich-editor',
    reuse: true
  },
  {
    path: '(.*)',
    component: 'my-not-found'
  }
];

Guards

Guards run sequentially before the route's enter callback. Return undefined to allow, or use commands.redirect()/commands.prevent() to block.

import type { RouteContext, RouteCommands, RouteActionResult } from '@wincc-oa/wui-router/core/route-config.js';

const authGuard = async (context: RouteContext, commands: RouteCommands): Promise<RouteActionResult> => {
  if (!isLoggedIn()) {
    return commands.redirect('/login');
  }
  return undefined;
};

const adminGuard = async (_context: RouteContext, commands: RouteCommands): Promise<RouteActionResult> => {
  if (!isAdmin()) {
    return commands.prevent();
  }
  return undefined;
};

const route: WuiRouteConfig = {
  path: '/admin',
  component: 'admin-panel',
  guards: [authGuard, adminGuard]
};

Named Outlets

Routes can render content into multiple DOM regions beyond the primary outlet.

Using <wui-router-outlet>

The <wui-router-outlet> web component self-registers with the router on connect, and unregisters on disconnect. The name attribute defaults to 'main', so a single-outlet app can write <wui-router-outlet> without any attribute.

import '@wincc-oa/wui-router/components/wui-router-outlet.js';
<!-- Single-outlet default: registers as 'main'. -->
<wui-router-outlet></wui-router-outlet>

<!-- Named outlet, addressable via ?$.info-bar=/some/path in the URL. -->
<wui-router-outlet name="info-bar"></wui-router-outlet>

Name uniqueness policy

Outlet names must be unique within a page. The policy differs by intent:

  • Explicit duplicate (two outlets with the same name= attribute): the second throws during connectedCallback with a clear error. This surfaces the collision loudly since the author explicitly asked for it.
  • Default-name collision (two outlets without a name attribute — both want 'main'): the second becomes passive. It renders <slot name="empty"> and does NOT register with the router, so the page still works. The host gets a data-passive="true" attribute for DevTools inspection. Fix by giving one of them an explicit name.

Style it from the host component — it's a plain HTMLElement:

wui-router-outlet[name='info-bar'] {
  display: block;
  position: fixed;
  bottom: 0;
}

wui-router-outlet[name='info-bar']:empty {
  display: none;
}

Outlet renderers in route config

const route: WuiRouteConfig = {
  path: '/dashboard/:id',
  component: 'wui-dashboard',
  outlets: {
    'info-bar': (params) => {
      const el = document.createElement('span');
      el.textContent = `Dashboard #${params['id'] ?? ''}`;
      return el;
    }
  }
};

Outlets are merged from the matched route chain (parent → child). A child route's outlets override a parent's for the same name. Outlets that are not declared by the active route are cleared.

Outlet-scoped DI

When a <wui-router-outlet> connects, it opens a tsyringe child container. Into that child container the outlet can register outlet-scoped bindings: bindings whose value is specific to that outlet. A component rendered inside the outlet that resolves such a token gets the value scoped to THAT outlet. Any token the outlet does not override resolves as normal, so global singletons stay shared across every outlet.

A token becomes outlet-scoped in one of two ways:

  • Imperatively, when the outlet registers a value into its child container with child.register(TOKEN, { useValue: ... }). The outlet chooses the value, so each outlet can supply a different one.
  • Declaratively, when a service marks itself @scoped(Lifecycle.ContainerScoped). tsyringe then creates a separate instance of that service per child container automatically, so each outlet gets its own instance without the outlet registering anything. This is the mechanism for a service that must hold per-outlet state.

For example, the router uses the imperative way to give each outlet its own view over the shared router. It registers a WuiOutletRouterService, bound to that outlet's name, under WuiRouterFacadeToken in the child container. A component resolving WuiRouterFacadeToken inside the outlet therefore reads that outlet's routing state (its location and searchParams) rather than the main route's.

A service scoped per outlet

Mark a service @scoped(Lifecycle.ContainerScoped) (both exports come from tsyringe) so that tsyringe hands each outlet its own instance:

import { scoped, Lifecycle } from 'tsyringe';

@scoped(Lifecycle.ContainerScoped)
class MyOutletState {
  // one instance per outlet child container
}

A component inside the outlet reads it with @outletInject(MyOutletState), the same consumer decorator used for imperatively registered tokens (see How a component reads an outlet-scoped token).

A service left as a normal @singleton() stays shared across all outlets, which is the default. @scoped(Lifecycle.ContainerScoped) is what opts a service into a separate instance per outlet.

How a component reads an outlet-scoped token

Resolve an outlet-scoped token with the @outletInject decorator (from @wincc-oa/wui-shared, see ../wui-shared/README.md for the decorator API). For a token an outlet registers per outlet, a plain container.resolve in a field initializer binds to the global value, not the outlet's. @outletInject resolves the token through the outlet's child container instead.

For example, to read the outlet-scoped router view:

import { outletInject } from '@wincc-oa/wui-shared/controllers/outlet-container/outlet-inject.decorator.js';
import { WuiRouterFacadeToken } from '@wincc-oa/wui-shared/tokens/wui-router-facade.token.js';
import { WuiRouterFacade } from '@wincc-oa/wui-models/interfaces/wui-router/wui-router.facade.js';

class MyPanel extends LitElement {
  @outletInject(WuiRouterFacadeToken)
  private readonly router!: WuiRouterFacade;

  render() {
    // read post-connect: this outlet's routing state, not the main route's
    const path = this.router.getCurrentUrl();
    const mode = this.router.getSearchParam('mode');
    return html`<div>${path} (mode: ${mode})</div>`;
  }
}

WuiOutletRouterService is an outlet-bound view over the shared WuiRouter. It exposes the same router facade surface as the global router service, but every read is scoped to a single outlet name.

Lifecycle Hooks

Components rendered by the router can implement lifecycle methods. The outlet calls them structurally (duck-typed by method name), so implementing an interface is not required for a hook to fire.

For compile-time type-safety on the hook signature, the enter/leave hooks have marker interfaces you can implements:

import type { BeforeEnterObserver, BeforeLeaveObserver } from '@wincc-oa/wui-router/interfaces/lifecycle-observers.js';
import type { WuiRouterLocation } from '@wincc-oa/wui-router/router.js';

class MyPage extends LitElement implements BeforeEnterObserver, BeforeLeaveObserver {
  onBeforeEnter(location: WuiRouterLocation): void {
    this.dashboardId = Number(location.params['id']);
  }

  onBeforeLeave(location: WuiRouterLocation): void {
    this.cleanup();
  }

  onAfterEnter(location: WuiRouterLocation): void {
    this.startPolling();
  }

  onAfterLeave(location: WuiRouterLocation): void {
    this.stopPolling();
  }
}

There is one marker interface per hook: BeforeEnterObserver, BeforeLeaveObserver, AfterEnterObserver, AfterLeaveObserver — implement any subset.

For a real navigation A → B the outlet invokes, in order: onBeforeLeave(A)onBeforeEnter(B) → DOM swap → onAfterLeave(A)onAfterEnter(B). On the first mount there is no outgoing element, so only the enter hooks fire. When an outlet is cleared (its $.<name> URL binding is dropped) the leave hooks fire on the outgoing element with the last known location.

Hooks are notify-only - they cannot cancel navigation

All four hooks are notification only. Each is awaited (so async setup or teardown completes before the next step), but its return value is ignored — returning false, throwing, or rejecting does not stop the swap.

The reason is structural: the router commits the URL before the outlet mounts, so by the time an outlet hook runs there is no clean way to revert the address bar. Blocking a swap there would leave the URL and the visible screen out of sync.

To actually prevent navigation (e.g. unsaved changes → confirmation dialog), use BeforeLeaveController (@wincc-oa/wui-shared). It listens for the navigateTo event and can stopPropagation() before the router commits the URL, so cancelling leaves no inconsistent state. See wui-dashboard-edit for the reference pattern (dirty-check + confirm + re-dispatch with userConfirmed: true).

Remount vs. reuse

By default, every navigation to a route creates a fresh element instancedocument.createElement(component) runs per navigation. The previous element's disconnectedCallback fires, the new element's connectedCallback fires. Data subscriptions, controllers, and DOM state all start fresh.

Set reuse: true on a route config to opt into element caching for that route only. The router then reuses the same instance across navigations to that route; property changes drive updates via Lit's willUpdate/updated hooks. Use reuse sparingly — only when the element holds expensive-to-recreate state that must survive navigation.

Guidance:

  • Default (remount) is the right choice for most routes. It is what existing app components were written for.
  • reuse: true is useful for routes where the element holds a controlled editor session, streaming websocket state, or in-flight upload progress that should persist while the user navigates within the reuse scope.
  • Under reuse: true, subscriptions started in connectedCallback do NOT re-run on property changes. The consumer is responsible for using Lit's reactive willUpdate(changed)/updated(changed) hooks to react to property mutations (e.g. dashboardId changing).

Async Children

Child routes can be lazily loaded:

const route: WuiRouteConfig = {
  path: '/dashboard/:id/*',
  component: 'dashboard-shell',
  children: async () => {
    const module = await import('./dashboard-children.js');
    return module.routes;
  }
};

Container styling

Routed components placed inside <wui-router-outlet> should use box-sizing: border-box on their :host if they combine width: 100% / height: 100% with padding. The outlet enforces overflow: hidden for multi-outlet isolation, so content-box sizing with additive padding will clip. See the shared webuiContainerStyles in webui-runtime for the canonical pattern.

Nested Routes

Routes with children can render a parent layout with a child outlet. The parent component includes a <wui-router-outlet> (without a name attribute), and the router renders the matched child into it.

How it works

  1. A parent route matches and its component is rendered into the main outlet
  2. The router waits for the parent component's updateComplete cycle
  3. It searches the parent's shadow DOM (then light DOM) for an unnamed <wui-router-outlet>
  4. The matched child route's component is rendered into that outlet
  5. This recurses for deeper nesting levels

Example

const routes: WuiRouteConfig[] = [
  {
    path: '/users/*',
    component: 'user-layout',
    children: [
      { path: '/', component: 'user-list' },
      { path: '/:userId', component: 'user-detail' },
      { path: '/:userId/settings', component: 'user-settings' }
    ]
  }
];
import '@wincc-oa/wui-router/components/wui-router-outlet.js';

@customElement('user-layout')
class UserLayout extends LitElement {
  render() {
    return html`
      <nav><!-- shared navigation --></nav>
      <wui-router-outlet></wui-router-outlet>
    `;
  }
}

Routes without a <wui-router-outlet> in the parent continue to work as before — the deepest matched child is rendered directly into the main outlet.

Public API

Types

| Export | Module | Description | | --------------------- | ----------------------------------- | ------------------------------------------------------------------------------ | | WuiRouteConfig | core/route-config.js | Route config with guards, outlets, children, action | | RouteContext | core/route-config.js | Context passed to guards (pathname, search, hash, params) | | RouteCommands | core/route-config.js | Commands for actions/guards (redirect(), prevent(), component()) | | RouteActionResult | core/route-config.js | Action/guard return type (redirect, prevent, component, or undefined) | | WuiLocation | core/route-config.js | Minimal location shape (params, outlet?) | | MatchedRoute | core/routes.js | A matched route in the chain (route, params, pathname) | | WuiRouterLocation | router.js | Rich location passed to lifecycle hooks (pathname, search, params, route, ...) | | BeforeEnterObserver | interfaces/lifecycle-observers.js | Marker interface for onBeforeEnter(location) | | BeforeLeaveObserver | interfaces/lifecycle-observers.js | Marker interface for onBeforeLeave(location) | | AfterEnterObserver | interfaces/lifecycle-observers.js | Marker interface for onAfterEnter(location) | | AfterLeaveObserver | interfaces/lifecycle-observers.js | Marker interface for onAfterLeave(location) |

Classes

| Export | Module | Description | | ------------------------ | ----------------------------------- | --------------------------------------------------------------- | | WuiRouter | router.js | Multi-outlet routing engine (one URL, one history, all outlets) | | WuiBaseRouterService | adapters/base-router-service.js | Abstract outlet-bound facade; all facade logic lives here | | WuiMainRouterService | adapters/main-router-service.js | main-outlet facade implementing WuiRouterFacade | | WuiOutletRouterService | adapters/outlet-router-service.js | Outlet-scoped facade created per <wui-router-outlet> | | WuiRouterOutlet | components/wui-router-outlet.js | Self-registering <wui-router-outlet> web component | | Routes | core/routes.js | Core route matching controller |

Functions

| Export | Module | Description | | ------------------ | ---------------------- | -------------------------------------------------- | | createCommands() | core/route-config.js | Creates a RouteCommands object for use in guards | | getPattern() | core/routes.js | Gets/creates cached URLPattern for a route config |