Hologram v0.11: Regular Expressions, Client Stacktraces, and More

v0.10 was about events and middleware. v0.11 is mostly about the browser catching up with the server. Elixir regexes run there now, and errors raised on the client carry the same messages and stacktraces as errors raised on the server. In your templates, two things that used to be locked in at compile time no longer are: a component module or tag name can come from data, and so can a whole bag of attributes or props. Plus umbrella project support, and a fix for a subtle DOM identity bug that could knock focus out of an input mid-typing.

Regular Expressions in the Browser

Elixir regexes used to be server-only. Reach for ~r/.../, =~, or anything in Regex in client-side code and it simply wasn't there.

The obvious fix is to hand the pattern to JavaScript's RegExp and let the browser deal with it. That doesn't hold up. Elixir regexes are PCRE2, and the two dialects disagree in ways you'd hit in production rather than in a demo: possessive quantifiers, atomic groups, \A and \z, capture and newline conventions, how unicode and caseless interact.

The dialect gap is the easy part. Some PCRE2 constructs have no JS equivalent at all: recursion, subroutine calls, conditionals, \K, \X, backtracking verbs. Recursion is the obvious one. It needs a stack, and a single RegExp has nowhere to put one. Worse are the ones that look like they translate and don't: duplicate group names, a backreference to a group that never participated, \w and \b under caseless Unicode. Rewrite those into JS and the pattern still runs. It just stops agreeing with the BEAM.

So v0.11 ships all three: a PCRE2 parser, a translator, and an interpreter. Each pattern gets parsed once, then routed. If every construct in it maps onto a native RegExp with identical semantics, the native RegExp is what runs, at full browser speed. Most patterns go that way, including plenty the translator has to rewrite first. The rest fall through to Hologram's interpreter. Either way the result is what the BEAM would have given you. The Regex API, the =~ operator and the underlying :re functions all work, including regexes compiled at runtime and regexes crossing the client-server boundary in state or action params.

⚠️ Heads up: minimum versions changed

Hologram now needs Elixir 1.19 and OTP 28.1 or later, up from Elixir 1.15. This is what the regex work is built on: OTP 28 is where the BEAM's own :re moved to PCRE2, and agreeing with the server means running the same thing it runs. The Installation page has the current requirements.

Issue #961, PR #970 - 135 commits, over 18,000 lines.

Client Errors That Read Like Server Errors

v0.10 brought try/rescue/catch/after to the browser. What it didn't bring was the part you actually stare at when something breaks: the message and the stacktrace. Client errors were close to their server counterparts but not the same, and __STACKTRACE__ was always an empty list.

In v0.11 they're identical, in every environment. Getting there meant porting the BEAM's own error formatters, so a bad argument to a client-side :lists, :maps, :binary, :math, :re or :unicode call produces the same explanation the BEAM would print, down to which argument was wrong and why. FunctionClauseError renders the arguments it was given and the clauses it tried, exactly as Elixir does.

Stacktraces are real now too, behind a config option that defaults to true in dev and test:

config :hologram, client_stacktraces: true

Enabled, the compiler emits source metadata into the bundle and the runtime tracks a call stack, so errors carry Elixir frames naming module, function and arity, each pointing at the line its function had reached. __STACKTRACE__ holds those frames inside a rescue or catch, and reraise/2,3 preserves them. It's off outside dev and test on purpose: argument values end up in frames and can leave the device through a screenshot or a pasted console log, and the compiled Elixir in a bundle gets roughly a third larger over the wire.

A second setting, client_error_overlay, follows it unless you set it yourself. An uncaught client error reaches the console either way, in one entry carrying the Elixir report with the JavaScript stack below it. What the setting adds is the same report over the page, so you catch it without devtools open, and Escape or the dismiss button clears it. Compilation errors during live reload use the same overlay.

Issue #966, PR #979 - 263 commits across 256 files. The diff claims over 4.5 million lines, but 97% of that is four generated tables with a row per Unicode code point, used to check the ported identifier tokenizer against Elixir's across the whole code space. Both settings are documented on the new Configuration page.

Dynamic Nodes

Until now, every component and every HTML element in a template was decided when the template compiled, and rendering a widget picked at runtime meant an {%if} chain over every module it could possibly be.

A braced Elixir expression in tag-name position changes that. It's evaluated at render time, and its value decides what gets rendered: a component module renders that component, a string renders an HTML element with that tag name, anything else raises an ArgumentError.

<{@module} cid="my_component" title="Hello" />
<{@heading_tag} class="heading">{@text}</{@heading_tag}>

Nothing beyond the dispatch is new. <{MyButton} label="x"> is <MyButton label="x">. Props, attributes, event bindings, slots and spread all carry over unchanged. A tag with children closes by repeating the opening expression, so a closing tag still tells you what it closes however deeply the template nests. It also means a long expression gets written twice, which is a good reason to keep it short and put the logic in state or a helper.

The one constraint is bundling. The compiler ships a component when its module atom appears as a literal somewhere it can see: a template, a client-reachable function, init/3, command/3, or code that broadcasts an action. So a module put into state by init/3 works with nothing extra declared, but one conjured at runtime with Module.concat/1 or read out of the database never gets bundled and raises on the client. Worth knowing too: changing the module under a given cid replaces the component, so the outgoing one unmounts and the incoming one initializes from scratch.

Issue #973, PR #981. Documented in the new Dynamic Nodes section of the Template Syntax page.

Attribute and Prop Spread

The natural companion. A call site that can't name the module statically usually can't name the props either, so a braced expression prefixed with ... now injects a map's or keyword list's entries as attributes (on elements) or props (on components):

<div class="btn" ...{@html_attrs}>Content</div>
<MyComponent title="Hello" ...{@props} />

Dynamic nodes and spread are designed to be used together, with both the module and its props coming out of the data:

{%for widget <- @widgets}
  <{widget.module} ...{widget.props} />
{/for}

Spread adds no rules of its own. Every entry behaves exactly as if you'd written it out at that position, which also settles precedence: names resolve by position and the last one wins, so a literal before a spread is a default and a literal after it is forced. On elements, underscores become hyphens and nested maps compose dash-joined names, so data: [user_id: @id] renders data-user-id. On components, keys match declared prop names verbatim and values stay raw Elixir terms.

Two deliberate refusals: the expression must be a map or keyword list, with nil raising rather than spreading nothing, and event bindings can't be spread. A $-prefixed key raises, because silently not binding an event you meant to bind is worse than failing loudly.

Issue #974, PR #978. This also settles @RottenFishbone's request for attribute passthrough (#928). Documented in the new Attribute and Prop Spread section of the Template Syntax page.

Umbrella Projects

Hologram assumed a single-app project. On an umbrella, mix holo crashed at the root, asset paths resolved against the wrong directory, and at runtime the project's OTP application couldn't be identified at all.

v0.11 supports umbrellas end to end: mix compile from the root or a child app, mix holo from the root with live reload watching every child app's sources, and pages defined across several child apps served through one endpoint app, in dev and in releases.

Setup is deliberately lopsided. Only the child app owning the Phoenix endpoint gets the compiler entry, the Hologram.Router plug and the static path. Child apps that merely define pages take the :hologram dependency and nothing else, and their pages are discovered when the endpoint app compiles. One limitation, stated up front: one Hologram endpoint app per BEAM instance, with a descriptive error if a second one starts. The goal here is narrow, which is to make it possible for existing umbrella codebases to use Hologram at all.

Credit where it's due. Pavel Makarenko (@m1ome) was the first to report that mix holo crashes on an umbrella, and sent an initial PR (#864) that got this moving.

The crash turned out to be the first of about six failures, nearly all of them integration level: how Mix reads :listeners, stale per-app compile locks, consolidation paths, static paths resolving against the working directory. None of that is visible to unit tests, which is why #983 also adds an umbrella test suite of three child apps driven by Wallaby in a browser, running as its own CI job. That suite is what found every one of them.

Issue #875, PR #983. Documented in the new Umbrella Projects section of the Installation page.

Debounce and Throttle That Know About the Page

v0.10 added debounce(ms) and throttle(ms) as event modifiers, but a pending dispatch was unaware of anything happening around it. Submit a form and the submit action read state that still lagged the DOM, then the trailing dispatch landed afterwards and re-entered the pre-submit value. Navigate away and it fired anyway, into a page that no longer existed.

Both are lifecycle-aware now. A pending debounced dispatch flushes immediately when the element loses focus and when the enclosing form is submitted, and pending debounced and throttled dispatches are dropped on navigation. The principle: these modifiers are pure timing concerns, changing when a dispatch happens and never what the app observes at a boundary.

Worth remembering here: an element can carry several bindings for the same event, each with its own modifiers, timer and window, running in written order. Which gives you the typeahead pattern in one line, cheap work on every keystroke and expensive work only when typing stops:

<input $change="update_query" $change.debounce(300)="run_search" />

Reported by @RottenFishbone. Issue #963, PR #965.

Sibling Elements Keep Their DOM Identity

A bug fix, subtle enough to be worth explaining rather than listing.

A false {%if} used to render nothing at all, so the node vanished from its parent's child list. Child vnodes are keyless, so the diff matched siblings by position. When a conditional toggled on, a later sibling could get paired with the newly appearing element on the grounds of "same tag, same position" and be patched into it: its real DOM node gutted and rebuilt, losing focus mid-typing, resetting scroll, restarting media. Whether it happened at all was coincidental, depending on whether the far end of the child list happened to be stable.

Every other framework guarantees the same invariant here, that a conditional's state never changes sibling identity, and each solves it differently. What shapes Hologram's answer is that neither a block nor a component has to render a single root node, which keeps templates pleasant to write but means a block's output can be any number of nodes, so a lone placeholder in its position wouldn't be enough to delimit one. Hologram's fix: every template block now renders bracketed HTML comment markers around its output. They survive server rendering, which matters because the client diffs against a vdom derived from server-rendered markup and a comment's own text is the only carrier that round-trips. Those markers double as vnode keys, so a block is diffed as a keyed unit and its arity stops mattering. No template syntax changed.

Reported by @jamauro, with a diagnosis that named the mechanism, tested how three other frameworks avoid it, and proposed the fix. Issue #927, PR #985.

The same work vendored snabbdom, the virtual DOM library under the renderer, into the repository. Hologram renders blocks as fragment vnodes, and snabbdom's fragment support has a defect we needed fixed now: removing a text node from inside a fragment throws. Template indentation puts whitespace text inside every block, so switching a block off hits it on ordinary markup. The copy landed byte-identical to upstream, and each deviation is its own commit covered by a test that fails if an upgrade drops it.

More of Erlang and Elixir, Running in the Browser

Most of the standard library reaches the browser by being compiled from its own source. Some has to be hand-ported, either because it's a BEAM primitive or because its compiled form would cost far more in bundle size than a careful port. New this release:

Erlang: :application.get_application/1, :application.get_key/2, :erlang.system_info/1, :maps.is_iterator_valid/1, :re.compile/1,2, :re.run/2,3, :re.inspect/2, :re.import/1, :erl_erts_errors.format_error/2, :erl_erts_errors.format_bs_fail/2, :erl_stdlib_errors.format_error/2, :erl_kernel_errors.format_error/2, :elixir_config.identifier_tokenizer/0

Elixir: Code.ensure_loaded/1, Exception.format_stacktrace/1, FunctionClauseError.message/1, String.Tokenizer.tokenize/1

String.Tokenizer.tokenize/1 is a good example of the second reason: its generated form encodes the UTS 39 identifier tables as one guard clause per codepoint range, around 39 KB gzipped in every bundle. The port carries a compact range table and leans on the browser's own Unicode data instead.

This release also fixed a batch of inconsistencies in how already-ported Erlang functions reported errors, across string, binary, collection, math, URI and Unicode operations. Those only surfaced once client and server messages were being compared character by character, which is the sort of thing the error parity work was meant to shake out.

Documentation

Maintenance Release

v0.10.1 shipped on the 0.10 line, and its fixes are carried into v0.11:

Sponsors

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

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