back to home

2022-04-25

Page Specific Javascript with Phoenix LiveView and Esbuild

LiveView has a minimal Javascript footprint, but if you're building a client heavy app it can change. While building IndiePaper I implemented the realtime editor using JS hooks. This caused the app.js file to be around 1.4 MB. Since this file is loaded on every page, it wasted bandwidth and CPU time even when people didn't use the editor. This post outlines the technique I used to split the JS code and only load the required parts for each page.

Code Splitting and Dynamic Imports

Phoenix uses esbuild to bundle assets. Bundling is the process of taking code in separate modules and combining it into a single file, minifying and converting it to standard Javascript in the process. This is why app.js increases in size when we import more node modules. We can reduce the size by splitting the files based on the pages that require it and dynamically load the required parts only when needed.

Starting point

Consider that we have a LiveView app with two pages, one being a JS heavy text editor using TipTap . Initially we are going to have all the code reside in the app.js file.

import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";

...

let Hooks = {};
Hooks.SimpleTipTapHtmlEditor = {
  mounted() {
    const contentHTMLElementId = this.el.dataset.contentHtmlElementId;
    const editorElementId = this.el.dataset.editorElementId;

    window.tipTapHtmlEditor = new Editor({
      element: editorElement,
      content: contentHTMLElement.value,
    };
};

let liveSocket = new LiveSocket(socketHost, Socket, {
  params: { _csrf_token: csrfToken },
  hooks: Hooks});
        

This increases the size of the app.js file. Even though the code is only required in the editor page, it is downloaded and executed on every page.

Refactor to Modules

First step is to extract the code for the editor to a new function, export it from a new file simple-editor.js and include that in app.js.

# simple-editor.js
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";

export function setupSimpleTipTapHtmlEditor(
  contentHTMLElementId,
  editorElementId
) {
  const contentHTMLElement = document.getElementById(contentHTMLElementId);
  const editorElement = document.getElementById(editorElementId);

  window.tipTapHtmlEditor = new Editor({
    element: editorElement,
    content: contentHTMLElement.value,
  });
}
        

Setup Dynamic Import

Import that file in app.js and replace with the hook with the dynamic function call, and remove the old imports from the top.

# app.js
let Hooks = {};
Hooks.SimpleTipTapHtmlEditor = {
  mounted() {
    const contentHTMLElementId = this.el.dataset.contentHtmlElementId;
    const editorElementId = this.el.dataset.editorElementId;

    import("./simple-editor").then(
      ({ setupSimpleTipTapHtmlEditor }) => {
        setupSimpleTipTapHtmlEditor(contentHTMLElementId, editorElementId);
      }
    );
  },
};
        

The import statement has a different promise based form here. When the import is encountered, the chunk of code is fetched dynamically and then executed.

Setup Esbuild Chunking

When we enable Esbuild chunks, rather than bundling everything together, Esbuild creates different chunk files. Esbuild in Phoenix is configured with editing config/config.exs

# config.exs
...

# Configure esbuild (the version is required)
config :esbuild,
  version: "0.14.0",
  default: [
    args: ~w(js/app.js js/simple-editor.js
        --chunk-names=chunks/[name]-[hash] --splitting --format=esm --bundle --target=es2017 --minify
        --outdir=../priv/static/assets --external:/fonts/* --external:/images/*),
    cd: Path.expand("../assets", __DIR__),
    env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
  ]

        

We have added the file js/simple-editor.js to list of files, and added a set of options to enable splitting.

--chunk-names=chunks/[name]-[hash] --splitting --format=esm --bundle --target=es2017 --minify
        

Import as module

Finally you have to set the import of app.js in your root.html.heex as a module.

<script type="module" defer phx-track-static type="text/javascript" src={Routes.static_path(@conn, "/assets/app.js")}></script>
        

Extra bits

When you load JS files dynamically, there are some gotchas that you have to look out for.

Loading Delay

Dynamically loaded modules will only start fetching after app.js file has started executing. This leads to a slight delay, so it's better to inline critical parts in the file itself.

If you prefer, you can show a loading indicator while the file is being downloaded. The example setup requires AlpineJS. We declare isLoading on the element where the hook is added and set it to true. We use that Alpine variable to show a loading indicator. We disable the loading indicator when we recieve an event through x-on:hook-loaded

# Element where Hook is loaded
<div id="hook_element"
  phx-hook="SimpleHook"
  x-data="{isLoading: true}"
  x-on:hook-loaded="isLoading = false">
  <p x-show="isLoading">Loading Indicator</p>
  ...
</div>
        

When mounting the Hook, we have to sent a hook-loaded event to Alpine.

# simple-editor.js
function sendEditorLoaded() {
  let event = new CustomEvent("hook-loaded", {});
  context.el.dispatchEvent(event);
}

export function setupSimpleTipTapHtmlEditor(
  contentHTMLElementId,
  editorElementId
) {
  ...

  sendEditorLoaded();
}
        

Conclusion

With this setup, whenever you include a new Hook in a LiveView page, the corresponding module gets dynamically imported. The modules are chunked automatically and loaded on demand. This leads to only the pages that require the Javascript downloading and executing it.