IntersectionObserver mixin for web components
Tags: performance, webcomponents, haxtheweb, litelement
Published: 2021-04-23 15:12:29

{% youtube 9smCfXgaH40 %}
"One weird trick" we use for boosting performance of elements added to the DOM, is conditional rendering. We'll cover a few different methods we use for conditional / lazy rendering, first starting with a mixin approach.

If you're not aware of what an [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) is, it's basically a low level javascript API that allows us to generate an event when something is visible. This means that you can respond to the event of the user scrolling down in your app and "seeing" your element. By knowing when our element is visible, we can conditionally load... the whole the internals of the element for optimizing performance!

While incentivizing usage of LitElement, [IntersectionObserverMixin](https://www.npmjs.com/package/@lrnwebcomponents/intersection-element) is a vanillaJS mixin that works with any web component. Let's look at what it does first and then different examples, all of which are more or less the same.
## TLDWTRTC (Too long, don't want to read the code)
- We export a SuperClass which supports a `new IntersectionObserver`
- This has some reasonable defaults applied in `constructor` which can be changed in your implementation
- `connectedCallback` we activate the observer
- `disconnectedCallback` we disconnect the observer
- LitElement specific: We have a reactive property called `elementVisible`

## Show me the code (or skip if lazy-loading your memory)
```js
const IntersectionObserverMixin = function (SuperClass) {
  // SuperClass so we can write any web component library / base class
  return class extends SuperClass {
    /**
     * Constructor
     */
    constructor() {
      super();
      // listen for this to be true in your element
      this.elementVisible = false;
      // threasholds to check for, every 25%
      this.IOThresholds = [0.0, 0.25, 0.5, 0.75, 1.0];
      // margin from root element
      this.IORootMargin = "0px";
      // wait till at least 50% of the item is visible to claim visible
      this.IOVisibleLimit = 0.5;
      // drop the observer once we are visible
      this.IORemoveOnVisible = true;
      // delay in observing, performance reasons for minimum at 100
      this.IODelay = 100;
    }
    /**
     * Properties, LitElement format
     */
    static get properties() {
      let props = {};
      if (super.properties) {
        props = super.properties;
      }
      return {
        ...props,
        elementVisible: {
          type: Boolean,
          attribute: "element-visible",
          reflect: true,
        },
      };
    }
    /**
     * HTMLElement specification
     */
    connectedCallback() {
      if (super.connectedCallback) {
        super.connectedCallback();
      }
      // setup the intersection observer, only if we are not visible
      if (!this.elementVisible) {
        this.intersectionObserver = new IntersectionObserver(
          this.handleIntersectionCallback.bind(this),
          {
            root: document.rootElement,
            rootMargin: this.IORootMargin,
            threshold: this.IOThresholds,
            delay: this.IODelay,
          }
        );
        this.intersectionObserver.observe(this);
      }
    }
    /**
     * HTMLElement specification
     */
    disconnectedCallback() {
      // if we have an intersection observer, disconnect it
      if (this.intersectionObserver) {
        this.intersectionObserver.disconnect();
      }
      if (super.disconnectedCallback) {
        super.disconnectedCallback();
      }
    }
    /**
     * Very basic IntersectionObserver callback which will set elementVisible to true
     */
    handleIntersectionCallback(entries) {
      for (let entry of entries) {
        let ratio = Number(entry.intersectionRatio).toFixed(2);
        // ensure ratio is higher than our limit before trigger visibility
        if (ratio >= this.IOVisibleLimit) {
          this.elementVisible = true;
          // remove the observer if we've reached our target of being visible
          if (this.IORemoveOnVisible) {
            this.intersectionObserver.disconnect();
          }
        }
      }
    }
  };
};

export { IntersectionObserverMixin };
```

## Usage in practice

So when we leverage this in production we get timing as follows:
- Element definition loads (usually with dynamic `import()` from previous post)
- `render()` of element uses a ternary statement (LitElement specific) in order to conditionally render the contents of the element `${this.elementVisible ? html`render my element shadow html`: `render nothing by default` }`
- When `${this.elementVisibile}` changes to true we apply a `updated()` life-cycle callback (LitElement specific) in order to do additional dynamic `import()`s of internal elements used in the shadow of our element
- When the element is visible, it will disconnect the IntersectionObserver by default

This approach maximizes performance with minimal effort. It also allows for content authors in HAX to add elements to the page like `video-player` or `wikipedia-query` or `a11y-gif-player` and not have to think about performance yet obtain reasonably high scores for CMS driven, user generated content.

## What implementation looks like
Basically two or three things and you've got a faster element with a single dependency. These examples are from [wikipedia-query](https://www.npmjs.com/package/@lrnwebcomponents/wikipedia-query)
- import it:
```js
import { IntersectionObserverMixin } from "@lrnwebcomponents/intersection-element/lib/IntersectionObserverMixin.js";
```
- Conditionally render:
```js
// LitElement render function
  render() {
    return html` ${this.elementVisible
      ? html` <h3 .hidden="${this.hideTitle}" part="heading-3">
            ${this._title}
          </h3>
          <div id="result"></div>
          <citation-element
            creator="{Wikipedia contributors}"
            scope="sibling"
            license="by-sa"
            title="${this.search} --- {Wikipedia}{,} The Free Encyclopedia"
            source="https://${this
              .language}.wikipedia.org/w/index.php?title=${this.search}"
            date="${this.__now}"
          ></citation-element>`
      : ``}`;
  }
```
- Conditionally import / use the variable for conditional logic internally:
```js
updated(changedProperties) {
    changedProperties.forEach((oldValue, propName) => {
      // element is visible, now we can search
      if (propName == "elementVisible" && this[propName]) {
        import("@lrnwebcomponents/citation-element/citation-element.js");
      }
      if (
        ["elementVisible", "search", "headers", "language"].includes(
          propName
        ) &&
        this.search &&
        this.headers &&
        this.elementVisible &&
        this.language
      ) {
        clearTimeout(this._debounce);
        this._debounce = setTimeout(() => {
          this.updateArticle(this.search, this.headers, this.language);
        }, 10);
      }
    });
  }
```
Here we can see an additional conditional logic for `wikipedia-query` in that it uses `elementVisibile` along with having a `search` term, `language`, and `headers` in order to trigger a `fetch()` of the wikipedia public API. This means that if you have a wikipedia article embedded lower in your website, that your user won't invoke the API to load the article until it's in the viewport!

Some other code examples where we've implemented this IntersectionObserverMixin to juice performance on an element:
- [accent-card](https://github.com/elmsln/lrnwebcomponents/blob/master/elements/accent-card/src/accent-card.js)
- [a11y-gif-player](https://github.com/elmsln/lrnwebcomponents/blob/master/elements/a11y-gif-player/src/a11y-gif-player.js)
- [github-preview](https://github.com/elmsln/lrnwebcomponents/blob/master/elements/github-preview/src/github-preview.js)
- [video-player](https://github.com/elmsln/lrnwebcomponents/blob/master/elements/video-player/src/video-player.js)

This can be used with VanillaJS implementations as well by observering the attribute for changes, though no implementations exist here. You can also find a more advanced implementation in [lazyImageLoader](https://github.com/elmsln/lrnwebcomponents/blob/master/elements/lazy-image-helpers/src/lazy-image-helpers.js#L11) which is a SuperClass that mixes in IntersectionObserverMixin to do a lazyloading image placeholder SVG.