@lwrjs/router
TypeScript icon, indicating that this package has built-in type declarations

0.12.2 • Public • Published

LWR Routing & Navigation

Introduction

The @lwrjs/router package provides modules for client-side routing (lwr/router) and navigation (lwr/navigation), which export APIs to create a router, navigate, generate URLs and subscribe to navigation events. Client-side routing enables the creation of a Single Page Application (SPA).

LWR routers can be customized with configuration and hooks. They can also be nested, to create a hierarchy in an application.

See the RFC on LWR routing APIs here.

Router

A router is a piece of code that manages client-side navigation changes. All navigation events flow through a router for processing. Use the createRouter(config: RouterConfig) API to initialize a LWR router:

import { createRouter } from 'lwr/router';
createRouter({
    routes: [
        /* see Route Definitions section */
    ],
    basePath: '/my-site',
    i18n: {
        locale: 'es',
        defaultLocale: 'en-US'
    }
    caseSensitive: true,
});

It takes a configuration object as an argument:

type RouterConfig = {
    routes?: RouteDefinition[]; // see Route Definitions section, default = []
    basePath?: string; // a path prefix applied to all URIs, default = ''
    i18n?: I18NRouterConfig; // a i18n config that may effect the path prefix applied to all URIs, default = {locale: 'en-US', defaultLocale: 'en-US'}
    caseSensitive?: boolean; // true if URIs should be processed case sensitively, default = false
};

Locations

The router processes incoming navigation events (i.e. location changes), which enter the router as page references. A page reference is a location in JSON form, passed to the router via the navigate() API

interface PageReference {
    type: string;
    attributes: { [key: string]: string | null };
    state: { [key: string]: string | null };
}

The router uses its route definitions to determine if a location is valid. If so, the navigation event is accepted and a user will see updated content in their browser.

Route Definitions

The most important part of the RouterConfig is the array of route definitions. The router uses these to verify and process incoming location changes. A location is only valid if it can be matched to a RouteDefinition. The application will fail to navigate given an invalid location. Each RouteDefinition has this shape:

interface RouteDefinition<TMetadata = Record<string, any>> {
    id: string;
    uri: string;
    page: Partial<PageReference>;
    handler: () => Promise<{ default: RouteHandler }>;
    patterns?: { [paramName: string]: string };
    exact?: boolean;
    metadata?: TMetadata;
}

containing the following properties:

  • id: each RouteDefinition must have a unique identifier
  • uri: a string pattern for URIs which match this RouteDefinition; the grammar is fully defined in an RFC and includes these characters:
    • /: path separator
    • :parameter: captures a variable from a path or query parameter; must be alpha-numeric (i.e. [a-zA-Z0-9])
    • ?: denotes the beginning of the query string
    • &: query parameter separator
  • page: shape for page references which match this RouteDefinition; the usage is detailed in an RFC and allows:
    • :parameter bindings: map a path or query parameter from the uri to an attributes or state property
    • literal bindings: hard-code the type, an attribute, or state property to a literal value
  • handler: a Promise to a module which is called when a RouteDefinition is matched by a location; see the Route Handlers section
  • patterns (optional): a regular expression which a parameter must match in order to be valid; described in an RFC here
  • exact (optional, default = true): see the Nesting Router Containers section
  • metadata (optional): developer-defined metadata attached to RouteDefinition; see the RFC here and a recipe here

Important: The routes array seen in LWR app configuration is for server-side routes (see RFC here), and is unrelated to the @lwrjs/router package.

Route Matching

Here is an example RouteDefinition for a recipe:

// RouteDefinition for a page in a recipe website
{
    id: 'recipe',
    uri: '/recipes/:category/:recipeId?units=:units&yummy=yes',
    patterns: {
        recipeId: '[0-9]{3}',      // "recipeId" must be a 3 digit number
    },
    page: {
        type: 'recipe_page',       // matching page references must be of type "recipe_page"
        attributes: {
            recipeId: ':recipeId', // straightforward attribute binding
            cat: ':category',      // bind the "category" path parameter to the "cat" attribute
            units: ':units',       // bind the "units" query parameter to an attribute
        },
        state: {
            code: 'abc123',        // hard-coded state literal
        },
    },
    handler: () => import('my/recipeHandler'),
}

This URI and page reference match the recipe RouteDefinition:

// URI -> https://www.somewhere.com/recipes/desserts/010?units=metric&yummy=yes&extra=foo (extra query params are allowed)

// page reference:
{
    "type": "recipe_page", // type matches
    "attributes": {
        // all bound attributes have values
        "recipeId": "010", // the "recipeId" matches the pattern
        "cat": "desserts",
        "units": "metric"
    },
    "state": {
        "code": "abc123", // the state literal matches
        "extra": "foo"    // extra state properties are allowed
    }
}

This URI and page reference do not match:

// URI -> https://www.somewhere.com/r/desserts/abc (parameters and literals are missing or malformed)

// page reference:
{
    "type": "awful_page", // type DOES NOT match
    "attributes": {
        "recipeId": "lol", // the "recipeId" DOES NOT match the pattern
        "extra": "bad" // extra attributes ARE NOT allowed
        // the "cat" and "units" attributes are missing
    },
    "state": {
        "code": "fail" // the state literal IS NOT equal
    }
}

See more RouteDefinition examples with matching and non-matching page references in the RFC.

Route Handlers

When the router matches an incoming location to a RouteDefinition, it accesses its RouteDefinition.handler to determine the associated "view". A view is the component to display when the application navigates to a location.

// types related to the RouteHandler
interface RouteDefinition {
    handler: () => Promise<{ default: RouteHandler }>; // `export default` a RouteHandler module
    // other properties...
}
interface RouteHandler {
    new (callback: (routeDestination: RouteDestination) => void): void; // denotes a Class
    dispose(): void;
    update(routeInfo: RouteInstance): void;
}
interface RouteDestination {
    // provided by `RouteHandler.update()` via a callback
    viewset: ViewSet;
}
interface ViewSet {
    [namedView: string]: (() => Promise<Module>) | ViewInfo;
}
interface ViewInfo {
    module: () => Promise<Module>;
    specifier: string;
}
interface RouteInstance {
    // location information passed to `RouteHandler.update()`
    id: string; // RouteDefinition.id
    attributes: { [key: string]: string | null };
    state: { [key: string]: string | null };
    pageReference: PageReference;
}

Note: Modules are always provided via promises. This allows the module code to be lazily loaded, which improves performance of the application.

Given information on the current location (i.e. a RouteInstance), the job of the RouteHandler module is to provide a set of views via a callback from its update() function:

// "my/recipeHandler" RouteHandler module
import type { Module, RouteHandlerCallback } from 'lwr/router';

export default class RecipeHandler {
    callback: RouteHandlerCallback;

    constructor(callback: RouteHandlerCallback) {
        this.callback = callback; // Important: maintain a reference to the callback
    }

    dispose(): void {
        // perform cleanup tasks
    }

    update(routeInfo: RouteInstance): void {
        // called every time a RouteDefinition with this handler matches a location during processing
        const {
            attributes: { cat }, // location information
        } = routeInfo;
        const category = cat || 'entree'; // cat may be null
        const viewSpecifier = `my/${category}Recipe`; // e.g. "my/dessertRecipe"
        this.callback({
            viewset: {
                // return view component info based on the recipe's category
                default: {
                    module: (): Promise<Module> => import(viewSpecifier),
                    specifier: viewSpecifier,
                },
            },
        });
    }
}

See the RouteHandler RFC here and some example handlers here.

Generated Routers

The Router Module Provider can generate a router based on a static JSON file. A generated router consumes its configuration from a portable JSON file rather than a JavaScript module. Static configuration can be easier to author and to maintain. This approach is most helpful for straightforward use cases.

Configuration

The Router Module Provider is not a default module provider, so it must be added to the project configuration. Learn more in Configure a LWR Project.

Add "@lwrjs/router/module-provider" as a dependency in package.json`.

// package.json
{
    "dependencies": {
        "@lwrjs/router/module-provider": "0.7.1"
    }
}

Register the Router Module Provider in lwr.config.json.

// lwr.config.json with the Router Module Provider and the LWR default module providers
{
    "moduleProviders": [
        "@lwrjs/router/module-provider",
        "@lwrjs/app-service/moduleProvider",
        "@lwrjs/lwc-module-provider",
        "@lwrjs/npm-module-provider"
    ]
}

When registering the module provider, optionally configure the directory location of the router JSON files.

// lwr.config.json
{
    "moduleProviders": [
        ["@lwrjs/router/module-provider", { "routesDir": "$rootDir/config/router" }],
        "@lwrjs/app-service/moduleProvider",
        "@lwrjs/lwc-module-provider",
        "@lwrjs/npm-module-provider"
    ]
}

If a configuration is not specified when registering the Router Module Provider, it uses this default configuration.

{
    "routesDir": "$rootDir/src/routes"
}

To automatically register client-side routes with the server, specify them as "sub routes" by pointing to their Route JSON file.

// lwr.config.json
{
    "routes": [
        {
            "id": "spa",
            "path": "/site",
            "rootComponent": "my/spa",
            "subRoutes": "$rootDir/src/routes/client.json"
        }
    ]
}

If the client-side routes contain these uris:

  • "/"
  • "/about"
  • "/:id" Then the LWR server will automatically register these paths:
  • "/site"
  • "/site/about"
  • "/site/:id"

This allows users to do a full page refresh on a client-side route without getting a 404.

Router JSON

The Router Module Provider generates a router module based on JSON configuration: LwrRouterConfig.

interface LwrRouterConfig {
    basePath?: string;
    caseSensitive?: boolean;
    routes: LwrConfigRouteDefinition[];
}

interface LwrConfigRouteDefinition<TMetadata = Record<string, any>> {
    // These properties are the same as in RouteDefinition
    id: string;
    uri: string;
    page?: Partial<PageReference>;
    patterns?: { [paramName: string]: string };
    exact?: boolean;
    metadata?: TMetadata;
    // These properties are different than RouteDefinition
    // A Route Definition must have 1 or the other, but not both
    handler?: string; // a STRING reference to the handler class
    component?: string; // a STRING reference to a page component
}

The LwrRouterConfig contains the same properties which are passed to createRouter(). The LwrConfigRouteDefinition contains the same properties as RouteDefinition, except for:

  • handler: A string reference to the RouteHandler class specifier, rather than a function.
  • component: A string reference to the view component specifier. This is a shortcut so the view component can be specified directly, without authoring a RouteHandler. A LwrConfigRouteDefinition must contain a handler or a component, but not both.

Note: LwrConfigRouteDefinition is pure JSON, which is why it cannot contain any functions like RouteDefinition does.

Here is a Router config example.

// src/routes/website.json
{
    "routes": [
        {
            "id": "home",
            "uri": "/",
            "component": "examples/home",
            "page": {
                "type": "home"
            },
            "metadata": {
                "title": "Home"
            }
        },
        {
            "id": "namedPage",
            "uri": "/:pageName",
            "handler": "examples/namedPageHandler",
            "page": {
                "type": "namedPage",
                "attributes": {
                    "pageName": ":pageName"
                }
            }
        }
    ]
}

Usage

Import and use a router generated using the JSON above.

// src/modules/my/app/app.js
import { LightningElement } from 'lwc';
import { createRouter } from '@lwrjs/router/website'; // "website" refers to src/routes/website.json

export default class MyApp extends LightningElement {
    router = createRouter();
}
<!-- src/modules/my/app/app.html -->
<template>
    <lwr-router-container router="{router}">
        <lwr-outlet></lwr-outlet>
    </lwr-router-container>
</template>

The generated router module specifier is: @lwrjs/router/<name of the JSON config file>. It provides a createRouter() function that is identical to the static createRouter() function, except that it does not take a routes array, since the routes are configured in the JSON file instead. If basePath or caseSensitive is specified in both the JSON file and the createRouter() call, then the latter takes precedence.

Server-side Rendering

See this documentation to learn about routing during server-side rendering.

Router Container

In order to use a router in an application, it must be attached to the DOM. This is done with a router container, provided by the lwr-router-container component.

A router container provides "navigation context", meaning that it is responsible for processing all navigation wires and events from its descendants in the DOM (e.g. my-nav and lwr-outlet in the example code below).

<!-- my/app/app.html -->
<template>
    <lwr-router-container
        router="{router}"
        onhandlenavigation="{handleNavigation}"
        onprenavigate="{preNavigate}"
        onpostnavigate="{postNavigate}"
        onerrornavigate="{errorNavigate}"
    >
        <my-nav></my-nav>
        <lwr-outlet><!-- See the Outlet section below --></lwr-outlet>
    </lwr-router-container>
</template>
// my/app/app.ts
import { LightningElement } from 'lwc';
import { createRouter } from 'lwr/router';
import { ROUTE_DEFINITIONS } from './routeDefinitions';

export default class MyApp extends LightningElement {
    router = createRouter({ routes: ROUTE_DEFINITIONS });
    approvedCategories = ['apps', 'entrees', 'sides', 'desserts'];

    handleNavigation(e: CustomEvent): void {
        console.log('navigate() called with page reference:', e.detail);
    }

    preNavigate(e: CustomEvent): void {
        const {
            next: {
                route: { pageReference },
            },
        } = e.detail;
        const {
            attributes: { cat },
        } = pageReference;
        console.log('navigation event incoming with page reference:', pageReference);
        if (!this.approvedCategories.includes(cat)) {
            // REJECT unapproved recipe categories
            e.preventDefault();
        }
    }

    postNavigate(e: CustomEvent): void {
        const {
            route: { pageReference },
        } = e.detail;
        console.log('navigated to page reference:', pageReference);
    }

    errorNavigate(e: CustomEvent): void {
        const { code, message } = e.detail;
        console.error(`navigation error -> ${code}: ${message}`);
    }
}

A router container requires a router, and fires these events:

  • onhandlenavigation: dispatched when navigate(pageRef) is called; event.preventDefault() cancels the navigation event; event.detail is the PageReference
  • onprenavigate: dispatched when a navigation event is received and a RouteDefinition match is found; event.preventDefault() cancels the navigation event; event.detail is a RouteChange
  • onpostnavigate: dispatched when a navigation event has completed; event.detail is a DomRoutingMatch for the current location
  • onerrornavigate: dispatched when there is an error processing a navigation event (e.g. no RouteDefinition match, prenavigate cancelation); event.detail is a MessageObject
// router container event payload types
interface DomRoutingMatch {
    url: string; // e.g. "/recipes/desserts/010?units=metric&yummy=yes"
    route: RouteInstance;
    routeDefinition: RouteDefinition;
}
interface RouteChange {
    current?: DomRoutingMatch; // the current location info
    next: DomRoutingMatch; // location info for the incoming nav event
}
interface MessageObject {
    code: string | number;
    message: string;
    level: number; // Fatal = 0, Error = 1, Warning = 2, Log = 3
}

See a simple routing recipe here.

Nesting Router Containers

A router container can have up to 1 child router. Each router is responsible for processing the navigation events from its descendants. Every RouteDefinition resolving to a view component which includes a child router must set exact to false:

// parent RouteDefinition for a page which includes a child router
{
    id: 'root',
    uri: '/parent/path',
    exact: false, // allow the parent and child router to resolve a URI together (i.e. "/parent/path/child/path&params)
    page: { type: 'home' },
    handler: () => import('my/someHandler'), // resolves a view containing a child router container
}

See a nested routing recipe here.

Outlet

It is the router's job to resolve view components for a given location, and it is the outlet's job to display those view components:

<!-- my/app/app.html -->
<template>
    <lwr-router-container>
        <lwr-outlet refocus-off onviewchange="{onViewChange}" onviewerror="{onViewError}">
            <div slot="error">View component cannot display</div>
        </lwr-outlet>
    </lwr-router-container>
</template>

The outlet uses the CurrentView wire to get the current view component, then displays it in the DOM. It has:

  • properties:

    • view-name: the key of the ViewSet entry to display; the default value is "default"
    • refocus-off boolean: if present, the outlet will not put the browser focus on the view component when it loads; refocusing is on by default as an accessibility feature
  • events:

    • onviewchange event: dispatched whenever the view component changes; event.detail is the view component class:
    type Constructor<T = object> = new (...args: any[]) => T;
    interface Constructable<T = object> {
        constructor: Constructor<T>;
    }
    interface ViewChangePayload {
        detail: Constructable;
    }
    • onviewerror event: dispatched whenever the view component fails to mount; event.detail is the error and stack:
    interface ViewErrorPayload {
        detail: {
            error: Error;
            stack: string;
        };
    }
  • slots:

    • "error": The contents of the error slot are shown whenever the view component fails to mount

See the RFC for the outlet here and the viewchange and viewerror events here.

Multiple Outlets

A RouteHandler may return multiple views:

import type { Module, RouteHandlerCallback } from 'lwr/router';

export default class HomeHandler {
    callback: RouteHandlerCallback;

    constructor(callback: RouteHandlerCallback) {
        this.callback = callback;
    }

    dispose(): void {}

    update(): void {
        this.callback({
            viewset: {
                // return multiple views
                default: (): Promise<Module> => import('my/home'),
                nav: (): Promise<Module> => import('my/homeNav'),
                footer: (): Promise<Module> => import('my/homeInfo'),
            },
        });
    }
}

Multiple outlets can be used to display all the current view components by setting different view-names:

<!-- my/app/app.html -->
<template>
    <lwr-router-container>
        <lwr-outlet view-name="nav"></lwr-outlet>
        <lwr-outlet><!-- default view --></lwr-outlet>
        <lwr-outlet view-name="footer"></lwr-outlet>
    </lwr-router-container>
</template>

Navigation Wires

The lwr/navigation module provides wire adapters from which components can receive information about navigation events.

CurrentPageReference

Get the current page reference from the router container:

import { LightningElement, wire } from 'lwc';
import { CurrentPageReference } from 'lwr/navigation';

export default class Example extends LightningElement {
    // Subscribe to page reference updates
    @wire(CurrentPageReference)
    printPageName(pageRef) {
        console.log(`Page name: ${pageRef ? pageRef.attributes.name : ''}`);
    }
}

Note: This wire is also available in Lightning Experience.

CurrentView

Get a reference to the current view component. The viewName configuration property is optional, and falls back to "default" if unspecified.

import { LightningElement, wire } from 'lwc';
import { CurrentView } from 'lwr/navigation';

export default class MyFooter extends LightningElement {
    // Subscribe to view component updates
    @wire(CurrentView, { viewName: 'footer' })
    viewCtor;
}

NavigationContext

Get a reference to a component's navigation context (i.e. its closest ancestor router container), for use with the navigation APIs:

import { LightningElement, wire } from 'lwc';
import { NavigationContext } from 'lwr/navigation';
import type { ContextId } from 'lwr/navigation';

export default class Example extends LightningElement {
    @wire(NavigationContext as any)
    navContext?: ContextId;
}

Navigation APIs

navigate()

Navigate programmatically:

import { LightningElement, api, wire } from 'lwc';
import { NavigationContext, navigate } from 'lwr/navigation';
import type { ContextId } from 'lwr/navigation';

export default class AboutLink extends LightningElement {
    @wire(NavigationContext as any)
    navContext?: ContextId;

    handleClick(event: Event): void {
        event.preventDefault();
        if (this.navContext) {
            navigate(this.navContext, {
                type: 'named_page',
                attributes: { name: 'about' },
            });
        }
    }
}

generateUrl()

Generate a URL for a page reference:

import { LightningElement, api, wire } from 'lwc';
import { NavigationContext, generateUrl } from 'lwr/navigation';
import type { ContextId, PageReference } from 'lwr/navigation';

export default class UrlGenerator extends LightningElement {
    @api pageReference?: PageReference;

    @wire(NavigationContext as any)
    navContext?: ContextId;

    connectedCallback(): void {
        if (this.pageReference && this.navContext) {
            const url = generateUrl(this.navContext, this.pageReference);
            console.log(`"${url}" is the URL for this page reference:`, this.pageReference);
        }
    }
}

Lightning Navigation

The @lwrjs/router package provides an implementation of lightning/navigation, which defines the NavigationMixin and the CurrentPageReference wire adapter. This allows a component to be written once and plugged in anywhere that supports the lightning/navigation contracts.

The lightning/navigation module is an alias for the lwr/navigation module, so it includes the same wires and APIs, along with the NavigationMixin.

Important Pick either lightning/navigation or lwr/navigation to use throughout your app. Otherwise, there could be JavaScript bundling clashes when running in prod mode.

NavigationMixin

Some developers may prefer to use the NavigationMixin over the navigate() and generateUrl() APIs. Both offer the same functionality, but only the NavigationMixin is compatible with Lightning Experience (LEX). So a developer writing a component for use in both LWR and LEX should choose the NavigationMixin.

import { LightningElement } from 'lwc';
import { NavigationMixin } from 'lightning/navigation';

const pageRef = {
    type: 'standard__recordPage',
    attributes: {
        recordId: '001xx000003DGg0AAG',
        objectApiName: 'Account',
        actionName: 'view',
    },
};

export default class Example extends NavigationMixin(LightningElement) {
    // Navigate to a page reference
    navPageRef() {
        this[NavigationMixin.Navigate](pageRef);
    }
    // Generate a URL for a page reference
    getUrl() {
        this[NavigationMixin.GenerateUrl](pageRef).then((url) => console.log(url));
    }
}

Readme

Keywords

none

Package Sidebar

Install

npm i @lwrjs/router

Weekly Downloads

961

Version

0.12.2

License

MIT

Unpacked Size

542 kB

Total Files

168

Last publish

Collaborators

  • kevinv11n
  • kmbauer
  • nrkruk
  • lpomerleau
  • lwc-admin