October 9, 2026
Micra 2.8: a kilobyte lighter, and three bugs found on the way
The build has a size check: if the gzipped bundle goes over the limit, it fails. After 2.7.1 the bundle sat at 7.9 KB against an 8 KB limit, and the next fix I needed — making data-bind="title:label(first, last)" stop splitting on the comma inside the call — cost about 90 bytes. It fit, with 26 bytes to spare.
I could have raised the limit. It has been raised before, each time with a reason written next to it. This time I went through the core module by module instead, measured every change in gzipped bytes, and kept only the ones that left the behavior exactly as it was. The result is 2.8.0: 7.0 KB, no API changes, and a few things I didn’t expect to find.
Where the kilobyte came from
Most of it came from the expression evaluator, the part that turns count > 0 or item.price * qty into a value. Since 2.4 it has been a small hand-written parser, so Micra never needs eval. Until now it worked in two steps: parse the expression once into a tree of plain objects, then walk that tree with a separate interpreter every time the expression was evaluated. A third pass went over the same tree to work out which state keys the expression reads.
In 2.8 the parser builds functions instead of objects. Simplified, for price + 10:
// before: the parser produced data...
{ k: "bin", op: "+", l: { k: "id", n: "price" }, r: { k: "lit", v: 10 } }
// ...and an interpreter walked it on every render (one switch case per node kind)
// now: the parser produces the evaluation itself
const price = (scope) => lookup("price", scope)
const ten = () => 10
const sum = (scope) => price(scope) + ten()
sum(state) // evaluating is one call
The node types, the interpreter and the dependency pass are gone; dependencies are collected while parsing. The tokenizer, which was a character-by-character loop, became one regular expression. Same grammar, same guards on member access. That alone took the bundle from 8166 to 7687 bytes. Speed stayed about where it was: in a quick benchmark, plain paths, ternaries and method calls came out slightly faster and arithmetic slightly slower, all within tens of nanoseconds per evaluation.
The rest is less interesting and adds up the same way. Keyed and non-keyed list rows now share one update function instead of three near-copies. data-on and @click had two scanners and two binders that did the same thing; now there is one of each. A handful of checks guarded against states that the code around them had already made impossible. Lists and directives gave another 280 bytes, events and mounting 244. Final number: 7163 bytes.
Three bugs
Reading the code this closely, with an eye on what each line is for, turned up three bugs. All three were in released versions.
data-show="true" evaluated to undefined. The old evaluator had a shortcut: anything that looked like a plain path, user.name or count, skipped the parser and was looked up in state directly. A bare true looks like a path too. So did false, null, NaN and Math.PI, and all of them came back undefined. Nobody writes data-show="true" by hand, but a server template does it all the time — data-show="<%= feature_enabled %>" renders exactly that. The shortcut is gone, since with the new evaluator a plain path costs a couple of property reads anyway.
Keyed rows could show a stale value. When the only thing that changed was the list itself, rows whose item hadn’t changed were skipped entirely — a reasonable optimization, unless a row reads the list:
<template data-each="items" data-key="id">
<p data-text="'Item ' + (index + 1) + ' of ' + items.length"></p>
</template>
Add a third item and the first two rows kept saying “of 2”. Rows like that are no longer skipped.
Commas inside calls split the pair. data-bind, data-class and data-on take comma-separated name:expression pairs, and the splitting happened on every comma. data-bind="title:label(first, last)" turned into two broken pairs. The fix that started all this: commas inside parentheses, brackets or quotes now belong to the expression.
One addition: quoted names
The same splitting had a second limit that matters if you use Tailwind. A pair’s name ends at the first colon, so data-class could not toggle a class like md:hidden — the name came out as md. In 2.8 the name can be quoted, and then it may contain colons:
<aside data-class="'md:hidden': collapsed, 'data-[open]:block': open"></aside>
Unquoted names work as before, so nothing that already worked changes.
How I checked it
A refactor like this is easy to get subtly wrong, so the test suite wasn’t the only check. Each bug got a test that fails on 2.7.1 and passes on 2.8.0. Beyond the test suite, I loaded every demo page from the repository and every Pro component and template under both the old and the new build, clicked every button and tab in order, and compared the rendered text and DOM after each click. Around 840 steps came out identical. The few differences on the first pass came from a chart demo that generates random data and from focus timing in a hidden browser window — the focus ones appeared between two runs of the old build as well, and went away on a repeat. The WordPress plugin’s blocks got the same treatment on a local install.
What changes for you
Probably nothing. If you load Micra from cdn.jsdelivr.net/npm/micra.js@2/…, you are already on 2.8.0. The Rails gem pins the @2 range by default as well. If you pin an exact version, bump it to 2.8.0; there are no breaking changes.
The size limit in the build went down from 8 to 7.5 KB. The half kilobyte of headroom is there for fixes like the one that started this, not for new features — the reason the core can stay this small is that most things don’t belong in it.
The full list is in the changelog, and the directives page covers the pair syntax.