@wincc-oa/wui-router
v2.0.1
Published
WinCC Open Architecture Dashboard - routing library based on @lit-labs/router.
Maintainers
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 hooks —
onBeforeEnter,onBeforeLeave,onAfterEnter,onAfterLeave - Async children — lazy-load child routes via
() => Promise<WuiRouteConfig[]> - Route actions —
action(context, commands)withcomponent(),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=liveNo 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 duringconnectedCallbackwith a clear error. This surfaces the collision loudly since the author explicitly asked for it. - Default-name collision (two outlets without a
nameattribute — 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 adata-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 instance — document.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: trueis 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 inconnectedCallbackdo NOT re-run on property changes. The consumer is responsible for using Lit's reactivewillUpdate(changed)/updated(changed)hooks to react to property mutations (e.g.dashboardIdchanging).
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
- A parent route matches and its component is rendered into the main outlet
- The router waits for the parent component's
updateCompletecycle - It searches the parent's shadow DOM (then light DOM) for an unnamed
<wui-router-outlet> - The matched child route's component is rendered into that outlet
- 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 |
