einarvalur.co

The humble Router

I have come to the conclusion that WebComponents are not a good way to build rich client-side applications. What they are good for is to enrich a single HTML element within a document of regular HTML elements.

This has worked quite well for me... This blog is generated by a Markdown parser that displays some Markdown text in HTML. Every so often I need something more than just the regular plain'ol HTML elements to get my point across. I might need some sound, some user interaction or some animations.

For that, WebComponents are a great way of enhancing the reader's experience. I can create a self-contained WebComponen (styles, scripts and all) and drop it into the Markdown file and everything works fine (if the browser supports it).

With WebComponents that can change state, one would like to maybe be able to persist the different stages. Let's say you have a WebComponent that has a blue box in it, if you click it, it turns red. Now you want to send a URL to a friend that will display the box in the red state.

Modern browser do have this capability, remember the humble hash sign #? It stores the state of the document and most browser interpret that as: to scroll to a specific section of the document.

To solve the WebComponent problem described earlier, this really feels to me like:

Map a string/URL to a function that can set the state of the WebComponent to the value provided by the hash-value.

Well, aren't you just describing a client-based Router?, Indeed I am... There have been a million-and-one routers written in the past years; this is my take on this it.

Design

My pre-requirements for this router implementation are: no dependencies and a very small footprint. Plus, written as a WebComponent.

I want to, not just match a string to a function, but to map a string-pattern to a function, so the matching logic will need to use regular-expressions.

I want the match function to act in a similar way to how resolvers work in Redux. The function should be able accept the WebComponent/HTMLElement that needs to change state, update its state and then return it, so it can be injected back into the document (or return a new WebComponent/HTMLElement if that is the result).

I want to be able to provide the Router with a map of many patterns and functions. In other words: I want the the routes to be injected into the Router Component.

I want the function to return a Promise, mostly because I might want to be able to stall the transition between states.

Lastly, I want the Router to be aware of the back and forward button of the browser.

Implementation

I decided to use Maps to configure the Router, mostly because it's cool. In TypeScript, the definition of the config will look like this:

type routes = Map<RegExp, (current: HTMLElement | undefined, params: {[key: string]: string} | null) => Promise<HTMLElement>>;

...and a very simple configuration would look like this

const routes = new Map()
    .set(/^\/$/, async () => Promise.resolve(document.createElement('x-list')))
    .set(/^\/item\/(?<id>[0-9]*)$/, async (current, params) => {
        const element = document.createElement('x-item');
        element.setAttribute('id', params.id);
        return Promise.resolve(element);
    });

Here I create two routes. The first one is listening for the slash (/) and if that is a match, it creates and returns a WebComponent registered as x-list wrapped in a resolved promise. It doesn't have to be wrapped in a Promise, I'm just making a point that the function should/could return a Promise.

The second route is listening for the pattern /item/<number> and if that is a match, it creates a WebComponent registered as x-item. Because the RegualExpression uses the group syntax (?<id>[0-9]\*), the params parameter will be {id: "1"}

Next we need to create a loop that goes through this Map, finds the pattern that matches, call our resolver function and replace the current state with the new state. We also need to store the new state under current so we can provide it next time we run the router. Lastly we break out of the loop if there is a match. The routes are therefor very much a "top-down" approach; the Router will yield on first match.

const container = document.getElementById('container');
const routes = new Map();
let current;
const path = `/${window.location.hash.substring(1)}`;
for (const [regex, fun] of routes) {
    let groups = path.match(regex);
    if (groups) {
        const child = await fun(current, groups.groups);
        this.current
            ? container.replaceChild(child, current)
            : container.appendChild(child);
        current = child;
        break;
    }
}

This is pretty much it for the logic. Now it is time to wrap this into a WebComponent. Apart from listening to the window's popstate event, there aren't that many changes that we need to cater for:

window.customElements.define('x-router', class extends HTMLElement {
    constructor(routes = new Map()) {
        super();
        this.routes = routes;
        this.current = undefined;
        this.onPopState = this.onPopState.bind(this);
    }

    async onPopState() {
        const path = `/${window.location.hash.substring(1)}`;
        for (const [regex, fun] of this.routes) {
            let groups = path.match(regex);
            if (groups) {
                const child = await fun(this.current, groups.groups);
                this.current
                    ? this.replaceChild(child, this.current)
                    : this.appendChild(child);
                this.current = child;
                break;
            }
        }
    }

    connectedCallback() {
        this.onPopState();
        window.addEventListener('popstate', this.onPopState);
    }
});

Demos

Let's have a look at how we would use this new WebComponent

Standard usage.

Here is a simple Router example, one that just switches between two pages. Mostly like a normal router would behave.

        
window.customElements.define('x-example-one', class extends HTMLElement {
    constructor() {
        super()
        this.attachShadow({mode: 'open'});
        this.shadowRoot.innerHTML = `
            <style>
                :host {
                    display: flex;
                }
            </style>
            <nav>
                <ul>
                    <li><a href="#">home</a></li>
                    <li><a href="#page">page</a></li>
                </ul>
            </nav>
            <main></main>
        `
    }
    connectedCallback() {
        const router = document.createElement('x-router');
        router.routes = new Map()
            .set(/^\/home$/, () => document.createElement('x-page-one'))
            .set(/^\/entity$/, () => document.createElement('x-page-two'));
        this.shadowRoot.querySelector('main').appendChild(router);
    }
});
        
    

One thing to note, is that a route doesn't have to return a new HTMLElement. It gets the current Element from the Router and the resolver can change its state and return the same element.

connectedCallback() {
    const router = document.createElement('x-router');
    router.routes = new Map()
        .set(/^\/home$/, (current) => {
            if (current) {
                return document.createElement('x-page-one')
            } else {
                // changing state and returning the same Element
                current.setAttribute('attribute', 'value');
                return current;
            }
        })
    this.shadowRoot.querySelector('main').appendChild(router);
}

Dynamic usage.

We could take advantage of the fact that the resolver function returns a Promise and dynamically load WebComponents as needed. In effect get code-splitting for free. This example is expecting that the WebComponents are exported as default object from respected files.

        
connectedCallback() {
    const router = document.createElement('x-router');
    router.routes = new Map()
        .set(/^\/group$/, () => {
            // use import() to import a module and create an instance of that the module exports
            const module = await import('../pages/x-page-one.js');
            return new module.default();
        }
        .set(/^\/person/(?<id>[0-9]*)$/, (current, params) => {
            // set loading animation on current Element
            current.setAttribute('loading', true);

            const module = await import('../pages/x-page-two.js');
            module.setAttribute('id', params.id)
            return new module.default();
        });
    this.shadowRoot.querySelector('main').appendChild(router);
}
        
    

Transition usage.

This one is slightly more complicated. The main takeaway from it is that: because the resolver function provides the current state, we can manipulate it (animate it) and release (return) the new state after all the animations have finished. Once the new state is released, we can then animate the new state... in effect, create transition-animation from one state to the other.

Make sure you go between the list and cards a few time to get the full effect of the transitions/animations

        
connectedCallback() {
    const router = document.createElement('x-router');
    router.routes = new Map()
        .set(/^\/group$/, this.listTransition)
        .set(/^\/person\/(?<id>[0-9]*)$/, this.itemTransition)
        .set(/.*$/, this.listTransition);
    this.shadowRoot.querySelector('main').appendChild(router);
}

async listTransition(current, params) {
    const list = await client.fetchList();
    const element = document.createElement('x-page-list');
    element.list = list;
    const listItems = element.shadowRoot.querySelectorAll('[data-list-item]');

    Array.from(listItems).map((item, i) => {
        item.style.opacity = 0;
        item.animate([
            { opacity: 0, transform: 'translateY(-25px)' },
            { opacity: 1, transform: 'translateY(0)'  }
        ], {
            // timing options
            duration: 500,
            delay: i * 100,
            fill: 'forwards',
                easing: 'cubic-bezier(.3,.45,.2,1.49)',
        });
    });

    return Promise.resolve(element);
}

async itemTransition(current, params) {
    const fetchedObject = await client.fetchItem(params.id);
    const newPageDocument = document.createElement('x-page-item');
    newPageDocument.setAttribute('title', fetchedObject.name);
    newPageDocument.setAttribute('url', fetchedObject.image);
    const listElements = (current && current.shadowRoot.querySelectorAll('li')) || [];

    const animations = Array.from(listElements)
        .filter(item => item.id !== `item-${params.id}`)
        .map((item, i) => (item.animate([
                { transform: 'translateX(0px)', opacity: 1 },
                { transform: 'translateX(-120%)', opacity: 0 }
            ], {
                duration: 300,
                delay: i * 100,
                fill: 'forwards',
                easing: "cubic-bezier(.64,-0.4,.5,1.01)"
            }).finished)
        );
    return Promise.all(animations).then(() => {
        if (current) {
            const selectedListElement = current.shadowRoot.getElementById(`item-${params.id}`)
            const cardElement = selectedListElement.querySelector('[data-card]');

            const animations = Array.from(cardElement.children)
                .map((child, i) => (child.animate([
                        { opacity: 1 },
                        { opacity: 0 },
                    ], {
                        duration: 200,
                        delay: i * 100,
                        fill: 'forwards',
                        easing: "cubic-bezier(.64,-0.4,.5,1.01)"
                    }).finished)
                );

            return Promise.all(animations).then(() => {
                const styles = getComputedStyle(cardElement);
                const rect = cardElement.getBoundingClientRect();
                const parentRect = current.getBoundingClientRect();
                const cardElementClone = cardElement.cloneNode(false);
                current.shadowRoot.appendChild(cardElementClone);
                cardElementClone.style.top = `${rect.top + window.scrollY}px`;
                cardElementClone.style.left = `${rect.left + window.scrollX}px`;
                cardElementClone.style.height = styles.height;
                cardElementClone.style.width = styles.width;
                cardElementClone.style.position = "absolute";

                cardElement.style.visibility = 'hidden';

                return cardElementClone.animate([
                    {
                        top: `${parentRect.top + window.scrollY}px`,
                        left: `${parentRect.left + window.scrollX}px`,
                        width: `${parentRect.width}px`,
                        height: `${parentRect.height}px`,
                        padding: '0',
                        borderRadius: '0',
                    },
                ], {
                    duration: 400,
                    fill: 'forwards',
                    easing: "cubic-bezier(.64,-0.4,.5,1.01)"
                }).finished.then(() => {
                    Array.from(newPageDocument.shadowRoot.children)
                        .forEach((child, i) => {
                            child.style.opacity = 0;
                                child.animate([
                                { opacity: 0 },
                                { opacity: 1 },
                            ], {
                                duration: 500,
                                delay: (i * 100) + 100,
                                fill: 'forwards',
                                easing: "cubic-bezier(.64,-0.4,.5,1.01)"
                            });
                    });
                    return newPageDocument
                });
            });
        } else {
            return newPageDocument
        }
    });
}
        
    

Conclusion

Routers are simple to write and with the power of WebComponents, ES6 modules and WAAPI some pretty amazing things can be implemented :)