Captain Codeman Captain Codeman

Polymer Tips: Prevent Inactive Views Responding To Other Route Changes

What to know about iron-pages and app-route

Contents

Introduction

Here’s a question that comes up a lot in Slack: you have multiple views in your Polymer app and notice that anytime the URL changes, they all respond and try to update, even the ones that are not visible. What’s going on? How do you make a view truly inactive?

If you’ve started your app based on the Polymer Starter Kit then you know it provides a great out-the-box setup complete with PRPL pattern and views as separate elements for lazy-loading.

Everything works great at this early stage because the only routing involved is the top-level switching between views (or fragments), there is no routing within views. Only when you start adding some per-view routing do you run into the issue. Here’s what’s going on and how to fix it.

First though, let’s quickly run through the pieces that make up routing in Polymer.

app-route Routing

The PSK top-level app element (the app shell) contains the single <app-location>, one of the Polymer app elements that handles interaction with the browsers address bar, together with the top level <app-route> instance to handle the initial route binding:

<app-location route="{{route}}"></app-location>
<app-route
    route="{{route}}"
    pattern="/:page"
    data="{{routeData}}"
    tail="{{subroute}}"></app-route>

The route data is bound to an aptly named routeData property on the app and the first path segment will become the page property of that object. That’s set from the :page part of the pattern attribute.

An observer is setup to watch this for changes whether this happens due to clicking a link in the app, typing the URL in the browser or changing the route-state programatically:

static get observers() {
  return [ '_routePageChanged(routeData.page)' ];
}

Whenever it changes the _routePageChanged function sets the page property of the element, defaulting to the home view if it’s empty (so an empty route can have a view even though there is no name). Another observer watches the pageproperty and loads the view element if it hasn’t already been loaded which is how Polymer provides lazy-loading.

iron-pages view switching

That’s the loading of the elements, the actual rendering and switching between them is handled by <iron-pages>. Here’s the default showing it wrapping the tags for the elements:

<iron-pages
    selected="[[page]]"
    attr-for-selected="name"
    fallback-selection="view404"
    role="main">
  <my-view1 name="view1"></my-view1>
  <my-view2 name="view2"></my-view2>
  <my-view3 name="view3"></my-view3>
  <my-view404 name="view404"></my-view404>
</iron-pages>

Note that none of those view tags actually do anything until the code for them is loaded and they are upgraded - this is part of what makes WebComponents / Custom Elements so good and allows for progressive loading of an app in the PRPL pattern.

iron-pages DOM

Initially none of the view elements within the <iron-pages> element are loaded. If you look at them in the DOM, they are empty because until the browser loads the code that defines them, they are unknown and don’t show up and have no children (if you ever add a new view and it doesn’t appear - you may have forgotten to add the element placeholder tags for it). Here’s the DOM with just view2 loaded, note the other views are empty while it has been upgraded:

iron-pages rendered in dom

It’s important to note that when you navigate around the app and cause the other elements to load and upgrade their placeholder tags, the previously loaded elements are still there and part of the DOM, just not visible - iron-pages handles the view switching by setting the selected view to visible and applying display: none to the others, it does not add and remove DOM elements.

You might now be thinking that this is terrible - the DOM is evil, bad and slow … right? Well no, doing lots of DOM manipulating in the wrong way can be slow, but if you want a slick responsive app it is usually better to keep the views loaded and available in order to switch quickly between them simply by changing the visibility.

hierarchical routing and sub-routes

The neat thing about the routing is that the entire system is hierarchical … each view element that loads can itself contain more routing Typically this involves passing the tail of the current route (stored as the subroute property above) into an element as the route which can then be observed in exactly the same way as above (just minus the <app-location> part):

<my-view1 name="view1" route="{{subroute}}"></my-view1>

multiple route observers

And this is where your issues could begin. Imagine you have multiple elements loaded all being passed this same subroute. Anytime that subroute changes all these elements that are observing the route might decide to respond to it. For example, suppose your app has a <my-topic> fragment to show topics expecting URLs like /topic/:id and a <my-post> fragment to show posts expecting URLs like /post/:id.

The first path segment causes the top-level iron-pages to switch between my-topic and my-post but each of these then has a route that’s watching the next segment. So going to /topic/123 or /post/456 will look fine on screen because only the matching top-level view will be visible, but the other element will still be reacting to the route change - which might mean redundant API requests or errors for IDs that don’t exist and wasted cycles as other parts of the view update the (hidden) DOM.

You might only notice these because you see spurious AJAX requests in the dev-tools network tab or errors in the console.

So, how do you prevent this from happening?

There are three different approaches you can follow:

solution 1: iron-page attribute

The Polymer shop demo app uses this approach which takes advantage of a feature of iron-pages to add a selected-attribute to the currently selected view element. For our app we could add a visible attribute to whichever element was, well, visible (and remove it from those that are not):

<iron-pages
    selected="[[page]]"
    attr-for-selected="name"
    selected-attribute="visible"
    fallback-selection="view404"
    role="main">

Any element that needs it can then add a visible: Boolean property which will automatically be set to true when it is the active element and false otherwise. By including this in any bindings the code can exit early:

static get observers() { return [
  '_itemChanged(item, visible)'
]}

_itemChanged(item, visible) {
  if (!visible) return;
  // handle item
}

Advantage: works everywhere Drawback: adds ‘visible’ to any binding in order to make it inert

solution 2: check route prefix

Another approach is to check the route prefix and, if it doesn’t match the one expected by the view, to avoid setting the properties that everything else in the view is based on.

static get observers() { return [
  '_routeChanged(routeData.id)'
]}

_routeChanged(id) {
  if (this.route.prefix !== '/topic') return;
  this.id = id;
}

Advantage: view disabling is in one place only Disadvantage: view needs to know it’s location

It might be possible to auto-set the prefix the first time it is accessed based on the assumption that it is set to the view used when it is first loaded and activated. But this might not be correct and would only work if the parent route was itself static.

solution 3: iron-resizable-behavior

If your element implements iron-resizable-behavior then not only will it be notified of resize events, it will also be notified when the element is shown and hidden. You can use this to either set the flag to use in option 1 or to determine whether to ignore route changes as per option 2.

Although you could add this to each element, this is the perfect situation to apply a Polymer mixin (previously a behavior in Polymer 1).

Here’s an example mixin that provides any element it’s applied to with a visible property that they can chose to use to make observer inert. It also calls any onShow and onHide methods when the view becomes activated.

The decision of whether the view is hidden or not is based off the clientWidth property which is 0 when hidden. I’m not 100% sure this works across all browsers but it’s worked in all the ones I’ve tried. You could also check the className for the iron-selected class that iron pages applies.

<link rel="import" href="../bower_components/polymer/polymer-element.html">
<link rel="import" href="../bower_components/polymer/lib/utils/render-status.html">
<link rel="import" href="../bower_components/iron-resizable-behavior/iron-resizable-behavior.html">

<script>
  (function() {
    /* @mixinFunction */
    window.ActivePageMixin = (superClass) => {
      return class extends Polymer.mixinBehaviors([Polymer.IronResizableBehavior], superClass) {
        static get properties() {
          return {
            visible: {
              type: Boolean
            }
          }
        }

        constructor() {
          super();
          this.onResizeBound = this.onResize.bind(this);
        }

        connectedCallback() {
          super.connectedCallback();
          this.addEventListener('iron-resize', this.onResizeBound, false);
          Polymer.RenderStatus.beforeNextRender(this, this.onResizeBound);
        }

        disconnectedCallback() {
          super.disconnectedCallback();
          this.removeEventListener('iron-resize', this.onResizeBound, false);
        }

        onResize(e) {
          this.visible = this.clientWidth > 0;
          if (this.visible) {
            if (this.onShow) this.onShow();
          } else {
            if (this.onHide) this.onHide();
          }
        }
      }
    }
  }());
</script>

conclusion

Whichever option you use, the key thing is that you can prevent views that are not active from responding to URL changes when the URLs don’t affect them.

BTW: Let me know it there is some other solution I can include or anything else I might have missed.