Hologram v0.12: Faster Compiles, Smaller Bundles, and More

More and more projects run Hologram in production now, and the apps keep getting bigger. The bigger ones ran into something small apps never notice. The compiler's cost grew with the app, and it paid that cost again on every run. On a very large app that meant minutes of waiting and most of the machine's memory. This release fixes that.

A cold build takes seconds. A save reloads the tab you're looking at in a second or two. A mix command with nothing changed skips the Hologram compile altogether. And bundles got a lot smaller, because a struct type's code now loads only when a struct of that type reaches the browser.

It's not all performance though. There's prop validation, props you can read from an action, and actions that run in the order you dispatched them.

The App Behind the Numbers

Almost every number in this post comes from one codebase. Evo Store is Hologram's Performance Sponsor, and their app is very large. It puts over 9,000 Elixir modules in front of the compiler, with about 160 Hologram pages.

Hologram v0.11.1 couldn't build it. On a 16 GB machine the compile ran out of memory and got killed after about four minutes, so Evo Store had to run a temporary fork to get by.

Evo Store came on as Performance Sponsor to get that fixed properly. Oliver Mulelid-Tynes (@olivermt) gave me their real app to profile against, which beats any benchmark I could've written myself, and he reported a good part of what's fixed here, including the reproduction behind #938 and the analysis that pinned down #891. Thank you, Oliver!

The work was tracked in two issues: #1256 for compile time, memory and live reload, and #1282 for bundle size. Timings below are from an M1 Pro with 16 GB, and they cover Hologram's part of a compile, not Elixir's.

A Compiler That Doesn't Start From Scratch

Once the first fix was in, the app at least compiled. A cold build took 290 seconds and peaked at 12.7 GB on that 16 GB machine, so most of the time went to swap. A compile with nothing changed took 107 seconds.

I went looking for the one big bug and there wasn't one. The compiler was correct. It was just wasteful in about two dozen places, and none of them hurt until an app got big. A few of them:

What I didn't expect is how much the fixes compounded. Each one removed whatever was hiding the next, so the gains multiplied. Forty milliseconds here, fifty there, and it adds up. A compile with nothing to rebuild went from 107 seconds to 72, then 32, then 4, then 1.5, then 0.05. The bundle size work fed back into it too. Smaller page bundles leave esbuild less to do, and that alone took a layout edit's full rebuild from 14 seconds to under 5.

By the end it was pretty much a rewrite of the call graph analysis and the bundling pipeline. 645 commits went in since v0.11.1, and close to 40,000 lines.

Here's where that leaves the same app on v0.12:

That middle one matters most for deploys. Outside dev and test, every mix command that needs the app compiled used to run the Hologram build again. Oliver's deploy job in #969 had five steps and ran the build four times, at roughly 3 minutes 20 seconds each. Now only the first one has anything to do.

Tracking issue #1256 - 25 issues, 24 PRs, 290 commits.

Live Reload Starts With the Tab You're Looking At

A save used to recompile and bundle every page of the app, then reload every connected tab. It didn't matter what you'd edited. You waited for all of it.

Now the compiler walks the call graph backwards, from the modules you edited to the pages that reach them, and rebuilds only those. Edit one page file in the 9,000-module app and 8 page bundles change, out of 158. The other 150 used to come out byte-identical and get rebuilt anyway.

Some edits still reach a lot of pages. The main layout is used by more than half of them. So the rebuild has an order. The server knows which page each tab is showing, because every tab holds a realtime stream. It builds the pages open in tabs first, then the pages those link to, then the rest in the background. A tab reloads as soon as its own page is ready, and a tab on a page the edit didn't touch doesn't reload at all. If you click through to a page that's still waiting for its rebuild, the request waits and that page jumps the queue.

On the 9,000-module app, after Elixir's own recompile:

Live reload events also moved to the SSE stream. That leaves the old WebSocket connection with almost nothing to do, and one step closer to being removed.

Issues #1241 and #1243, both part of #1256.

Struct Code Loads When a Struct Arrives

The compiler used to decide at build time which struct types a page could see. That was every type the page's client code names, plus every type init/3, its commands and its broadcasting code name. Then it shipped the protocol implementations and __struct__ functions for all of them. Most of those types never get anywhere near a browser.

Oliver's reproduction in #938 shows how bad that could get. A page built one Tempo value inside init/3, turned it into a string, and put only the string into state. That was enough to drag Tempo, Calendrical and Localize into the runtime bundle that every page loads. Minified, it went from 374 KB to 4.46 MB, or from about 80 KB to about 470 KB gzipped.

In v0.12 struct code isn't in the runtime bundle or in the page bundles. The compiler writes it to chunks, which are separate files per struct type, and the browser loads a type's chunks when a struct of that type actually reaches it:

def init(_params, component, _server) do
  # Loads Price's chunks: the struct reaches the state.
  price = %Price{amount: 1_000, currency: :EUR}

  # Loads nothing: only the string reaches the state.
  every = "Every #{Duration.to_iso8601(Duration.new!(minute: 30))}"

  put_state(component, price: price, every: every)
end

Before the server sends a value to the browser, it checks which struct types are in it and tells the browser which chunks the value needs. That goes for the state init/3 sets, an action a command returns, and an action the server broadcasts. The browser remembers which chunks it already has, so each one is fetched once and every later struct of that type costs nothing. It doesn't matter where the struct came from, so one read from the database loads its chunks the same way as one built in init/3. A module atom counts too. Put type: User into state and struct(type, name: "Ada") works in an action. And a page preloads the chunks for the struct types its own client code names, since those structs never pass through the server.

Reflection functions work the same way now. An Ecto schema's __changeset__/0 and __schema__/1,2 ship with a page only when the type can reach the page and something on the client can call the function on a module it doesn't name.

Bundle sizes on the 9,000-module app, minified, in a dev build:

The runtime bundle is the framework itself plus the parts of Elixir's standard library it relies on. Every page loads it, and the browser caches it after the first one. A page bundle is the page's own code: its templates, its actions and whatever they call. Page bundles gzip much better because templates and compiled Elixir repeat the same few constructs over and over.

Tracking issue #1282. Issues #891, #938 and #1317 - 125 commits. Documented on the new Bundling page.

There's a Lot Left

Hologram can get a lot faster than this. There are 15 open performance issues as I write this, and they cover the compiler, live reload, bundle sizes, page load and the code that runs in the browser. Rendering has a tracking issue of its own (#850). Plenty of them are small. But small ones are what took a no-change compile from 107 seconds to 0.05, each fix uncovering the next. So expect Hologram to keep getting faster with every release.

Prop Validation

prop used to accept any option you gave it. A typo like defualt: compiled fine and got quietly ignored, and you found out when the default never showed up.

Now an option prop doesn't know is a compile error. param takes no options yet, so passing it one is a compile error as well. And there are two new options, both actually enforced:

prop :size, :atom, required: true
prop :variant, :atom, values: [:primary, :secondary], default: :primary

Where a template spells the answer out, the build fails and tells you which component and which template:

** (Hologram.CompileError) prop "size" of component MyApp.Card must be one of [:small, :large], got: :huge, in MyApp.HomePage's template
** (Hologram.CompileError) component MyApp.Card is missing required prop "size" in MyApp.HomePage's template

That covers a missing required prop, and any value the compiler can work out without running anything. So plain text and literals, including lists, tuples and maps built from them. Everything else gets checked while rendering and raises Hologram.PropError: a prop that arrives through a spread, a component picked by a dynamic tag, a prop sourced from context, an interpolated value. Both renderers enforce it, server and client, so a component added after page load behaves the same.

Requested by @sodapopcan. Issue #772. Documented in the Options section of the Components page.

Props You Can Read in an Action

Until now, init got props as an argument and actions didn't. So an action that needed a prop's value had two choices: pass it through every event binding as a param, or copy it into state in init. The copy is a trap. init runs once, so the copy never updates when the parent passes something new.

Props are on the component struct now:

prop :label, :string

def action(:announce, _params, component) do
  put_state(component, :announced, component.props.label)
end

They follow whatever the parent passes, so an action always reads the current value. Defaults and context-sourced props are in there too, and so is cid. A page's params are its props, so a page action reads a URL param the same way, with component.props.id.

Issue #1053. Documented in the new Reading Props in Actions section of the Components page.

Actions Run One at a Time

@deviprsd found this one, and it's nasty. If any clause of a component's action/3 awaits a Task, the whole function compiles as async. An async function returns its result a microtask late. So an action read the component's state when it started, and Hologram saved the new state a moment after. The gap is tiny, so it usually took events fired from code in quick succession to land inside it. When they did, they all read the same state, and the last save won. Ten events gave you one update, without any error to tell you why.

Client actions used to wait in four different places depending on how they arrived, and none of those waited for the previous action to finish. v0.12 puts them all in one queue. A component's actions run one at a time, in dispatch order, and each one sees the state the previous one left.

The wait is per component and not per page. An action only ever writes its own component's state, so one that awaits a Task holds back later actions for that component and nothing else. It's the guarantee the BEAM gives you for messages: ordered between one sender and one receiver, no promises across processes. It also means a promise that never settles, say a permission prompt nobody answers, stalls one component and not the whole page.

Issue #1292. Fixes #1002. Documented in the new Execution Order section of the Actions page.

Realtime Streams on HTTP/2 and Bandit

Three fixes to the realtime stream. The test suite couldn't have caught any of them, because it ran plaintext HTTP/1.1 on Cowboy.

The test apps run on Bandit now, since that's what Phoenix generates. Cowboy has a test app of its own. And the realtime feature tests also run over HTTPS in CI, which is the only way to get a browser to negotiate HTTP/2.

Bug Fixes

Documentation

Maintenance Release

v0.11.1 shipped on the 0.11 line between the two feature releases, and it was a big one for a patch release. 36 issues went into it: 27 bug fixes, 4 performance improvements, 2 enhancements and 3 documentation updates. All of it is carried into v0.12. The ones I'd point at first:

Twelve of those 27 bugs, and one docs gap, came from people who ran into them and took the time to write them up. Thank you!

The rest of what went in: #980, #1024, #1028, #1029, #1032, #1045, #1046, #1067, #1074, #1075, #1076, #1100, #1106, #1130, #1140, #1146, #1148.

Sponsors

I'd like to thank our sponsors whose support makes sustained development possible:

We're working with the EEF on extending the stipend into a second round. What makes that possible is companies earmarking money for Hologram through the Foundation. If your company could do that, this post has the details.

Thanks also to our GitHub sponsors:

And to every other GitHub sponsor: thank you! Contributions of any size genuinely help keep Hologram going.

If you'd like to support Hologram's development, consider sponsoring the project.

Stay in the Loop

Subscribe to the Hologram newsletter for a monthly roundup of everything Hologram: new releases and features, a glance at what's coming next, ecosystem news and new libraries, and the discussions worth catching from the community and socials, all in one place. You can also join us on Discord, the main hub for questions, discussion, and announcements, or find every way to connect on the community page.

- Bart

Sponsored by
Curiosum
Main sponsor
Erlang Ecosystem Foundation
Milestone sponsor
Evo Store
Performance sponsor