Lazy-loading routes in TEA-style app?


#1

I’m using Pux for an app, and my app has grown large enough to take almost 20 seconds to load and render the initial request. It seems like bundle-splitting and lazy-loading modules or routes is the only way to improve this metric, but I can’t find any examples of how to do this for an application following The Elm Architecture. Does anyone here have any resources or tips which can help?

According to the Elm roadmap It looks like their community hasn’t figured out code-splitting or lazy-loading.

To be frank, I feel like a total fool for adopting an app base built according to “The Elm Architecture”. My app depends on SEO and a big part of that is the speed of first page load & render (in Google at least). When choosing TEA, I thought I could just add a Webpack plugin which “turns on” this ability, but I see now that lazy-loading requires me to re-architect my app a bit to use a lazy-load module syntax (i.e. require.ensure("Abc") or ES6+'s import("Abc")).

I find it amazing that TEA has no answer for this – the shortcomings of TEA aren’t enumerated anywhere, but they are in fact quite large. I feel it needs to be said that TEA is really disappointing, and that newcomers should think hard about the pros/cons of TEA vs a more hand-written architecture.

Following, I describe the results of my research on the various ways to accomplish code-splitting and lazy-loading.

One technique is lazy-loading every single UI element, as proposed by the react-loadable people. It looks like Webpack/Rollup can code-split that pretty simply, but to add support server-side rendering requires a special babel plugin for webpack. I hate Webpack with a passion - it’s so opaque - so I’d like to avoid that.

A different technique is to have a “router” which is separate from the app. The router would explicitly lazy-load the corresponding view, shown in the example in this gist. The problem with this is that that routes table is included with the first page response, which means that “secret, admin-only routes” can’t be included in the app.

The last technique is to write each route as a separate app – each route’s page would have a separate Main.purs and a user’s request for a subsequent route would hit the server which would respond with the JS app which renders that page, and the in-browser app would “activate” that lazy-loaded module by just executing it, like main();. To me, this seems like the most desirable option. The only problem is build-system becomes a bit obtuse. Also, I would lose the HMR and time-travel debugging gasp! (Actually I find HMR and TTDB to be pretty low-value things, so I’d be fine losing those.)

I minor problem with all of these options is that PureScript doesn’t have a type-safe module importer, as that would require first-class modules. A workaround is to expect each lazy-loaded page to have the same interface, a main function, which is probably ok.


#2

I think it’s unlikely that PureScript would ever get dynamic loading as part of the core language. For a general purpose language, I think it’s a bit of an odd feature, since it’s not that useful outside of SPAs. That said, I think it would be possible to implement a lazy loading solution into PS as some sort of corefn/backend pass or even just on the output JS itself in whatever bundler you are using. It may not look like the most elegant thing, but I’m sure it could be fairly robust. The key bit is “just” that you need to use require.ensure or dynamic import which of course we cannot surface in PS directly.

As an example, say we have this module:

module LazyTest where

import Prelude
import Effect.Random (random)
import Math (round)
import ModuleA as A
import ModuleB as B
import Module.Lazy (load)

main = do
  n <- round <$> random
  if n > 0
    then load "ModuleA"
           (\e -> Console.error e)
           (\_ -> A.doThis)

    else load "ModuleB"
           (\e -> Console.error e)
           (\_ -> B.doThat)

Here there are two modules we want to conditionally load (ModuleA, and ModuleB). A key bit is that we don’t really need first class modules, and we still use standard import syntax. The function load would be something like

foreign import load :: forall sym.
  String ->
  (Error -> Effect Unit) ->
  (Unit -> Effect Unit) ->
  Effect Unit

That is, opaque magic. And indeed the foreign implementation would be to just call the success handler. How does that give us lazy loading? It doesn’t as is, but it’s something that works with the existing compiler toolchain, and you’d get a working application if you did this, but without actually lazy loading anything.

In a separate corefn/backend pass you could replace these load calls with require.ensure, and you could replace any reference to the provided module with the dyamically imported package.

So as is the compiler might codegen the above code like:

var Prelude = require("../Prelude/index.js");
var Effect_Random = require("../Effect.Random/index.js");
var Math = require("../Math/index.js");
var ModuleA = require("../ModuleA/index.js");
var ModuleB = require("../ModuleB/index.js");
var Module_Lazy = require("../Module.Lazy/index.js");

main = function __do() {
  var n = ...
  if (n > 0) {
    return Module_Lazy.load("ModuleA")(...)(function (__unused) {
      return function () {
        return ModuleA.doThis;
      };
    });
  } else {
    ...
  }
};

That is, ModuleA and ModuleB are still statically referenced, so we don’t get anything (but it works!). If your corefn/backend pass could then translate this form into

var Prelude = require("../Prelude/index.js");
var Effect_Random = require("../Effect.Random/index.js");
var Math = require("../Math/index.js");
var Module_Lazy = require("../Module.Lazy/index.js");

main = function __do() {
  var n = ...
  if (n > 0) {
    return function () {
      require.ensure([], function (require) {
        var ModuleA = require("../ModuleA/index.js");
        ModuleA.doThis();
      });
    };
  } else {
    ...
  }
};

Here we’ve remove the ModuleA and ModuleB imports from the top level, and just moved them under the require.ensure call. You would need to do some validation, like the lazily imported modules can only be referenced under the load callback, and load must be called in a fully saturated form. This is kind of like what the compiler does with various Eff/Effect inlining. The foreign implementation is something that works (but maybe not ideal), and then when fully saturated it can replace it with a faster inline version.


#3

That’s a great idea and well-explained! It would be great to figure out a way it can be generic such that any PureScript project could use it. I’ve not seen any tools which do a “corefn/backend pass” – are you aware of any that I could use to reference as I work on it?

From a high-level view, here’s how I would guess a corefn solution would work. Is it what you meant?

$ purs compile "src/**/*.purs" ".psc-package\purs-0.12-foo\*\*\src\**\*.purs" --codegen corefn
$ someCoreFnPassProgram "output/**/corefn.json"
# Expect that to create corresponding .js file for each corefn.json file?
#   (But shouldn't I expect `purs` to codegen JS files?)
# Anyways, if it worked like this, next we'd need to code-split for in-browser.
$ rollup output/LazyTest/index.js output/ModuleA/index.js output/ModuleB/index.js -f system --dir bundles --experimentalCodeSplitting --experimentalDynamicImport
# Now `src/LazyTest.purs` will dynamically load and use ModuleA or ModuleB.
#   References to all files that could possibly be loaded are in `LazyTest`, but
#   I could move that to the server and choose the one which corresponds to
#   a the URL on an HTTP request.

You also mentioned a program which operates on output/LazyTest/index.js directly and creates, for instance output/LazyTest/index_rewritten.js. I guess that would require parsing that file into a JS AST, then rewriting and outputting it. It would be nice to include that rewrite pass with a tool which already does some amount of rewriting, such that the JS doesn’t need to be parsed into AST separately by multiple tools. I’m sure I can find something which makes that easy… (update: looks like rollup-plugin-purs pipelines things manually)

But to choose between the two methods, operating on corefn vs operating on th eJS output, a good question is could other backends ever support this? Is lazy/dynamic module loading only a feature of JavaScript?


#4

I don’t know of any language that supports conditional, asynchronous module loading besides JavaScript.

I personally would probably try to implement it as a plugin in my bundler.


#5

I was experimenting with lazy loading, and I came to a conclusion that we’re only missing type family that maps module identifiers to their types (i.e. type of the exported record). The rest could be implemented in ffi. I had a small working example using webpack, but I cannot find it right now.


#6

Unfortunately, it can not be farmed out to the FFI (at least reliably). require.ensure while it looks like a normal expression is treated as syntax by bundlers. Just like how you can technically call require with dynamic values, but it will fail if you try to bundle it. Dynamic import is the same. It better specifies what it means when you use it dynamically, but if you need to do any bundling you have to treat it as a syntactic form.


#7

I like this idea. I’m a frontend dev so being able to dynamically load code is important to me. One thing I would be worried about is using something from ModuleA outside of the scope where it’s being required. While we could check the .js code that’s generated to make this isn’t happening, communicating that info back to the developer will be tricky if we want to provide an error message that references the original .purs file itself.