Changelog
All notable changes to Micra.js will be documented in this file. Format follows Keep a Changelog, versioning follows SemVer.
[2.5.2] — 2026-06-15
Security hardening. No API changes; behaviour changes only for clearly-unsafe inputs, so upgrading from 2.5.2 is recommended and should be transparent.
Security
- CSRF token is now same-origin only.
this.fetch()attaches theX-CSRF-Token(read from<meta name="csrf-token">) only when the request URL resolves to the page origin. Previously it was attached to every request, so afetchto an attacker-influenced cross-origin URL could leak the token. data-bindrefuses XSS sinks. A binding can no longer install an inline event handler —data-bind="onclick: …"is dropped (use@click) — and ajavascript:URL bound to any attribute (href,src, …) is stripped. Both emit a dev-console warning.- Expression fast-path closed. Simple dot-paths such as
o.constructor/o.__proto__took a fast path that skipped the__proto__/constructor/prototypeblock applied on the AST path, leaving those names readable (never callable — RCE was already blocked). They now route through the interpreter and resolve toundefined, matching the documented model.
Changed
- Size budget raised from 7 KB to 7.5 KB gzip to absorb the hardening above. Real size is ~7.2 KB.
Note — the expression sandbox is defense-in-depth, not a boundary
The CSP-safe evaluator stops a directive expression from reaching
window/eval/Function, and CSP blocks injected inline scripts — but
neither protects against template/markup injection (client-side template
injection). Never interpolate untrusted input into directive attributes or
expressions; render user data via data-text / state only. Treat directive
markup as trusted code.
[2.5.2] — 2026-06-14
Ergonomics & safety release — three things the audience kept reaching for.
Added — key & system modifiers on events
-
@event/data-onnow accept key and system-key guards in addition to.prevent/.stop/.self:<input @keydown.enter="submit" @keydown.escape="cancel" /> <button @click.ctrl="openInNewTab">…</button> <textarea @keydown.ctrl.enter="send"></textarea>Key guards:
.enter.escape.tab.space.up.down.left.right.delete. System guards:.ctrl.shift.alt.meta. An unrecognized modifier matchesevent.keycase-insensitively. The handler runs only when the guard matches. (Previously you had to branch one.keyby hand — the docs even told you to.)
Added — dot-paths for nested state
this.set('user.name', 'Ada')— a path setter that reconstructs each nested level immutably and reassigns the top-level key, so the shallow proxy fires a render. No more hand-spreading nested objects.data-model="filters.search"—data-modelnow reads and writes dot-paths (same mechanism). Flat keys behave exactly as before.- The shallow-proxy model is unchanged; this is ergonomic sugar over it, not
deep reactivity. Bracket/computed paths (
filters[0]) are still literal keys — use dot notation. setjoins the reserved instance names (prop,fetch,emit,on,render,destroy) — a component method namedsetis shadowed by the builtin.
Added — data-html sanitizer hook
-
Micra.config({ sanitize })registers a function run on everydata-htmlvalue before it’s written. Micra does not bundle a sanitizer (size); opt into one in a line:import DOMPurify from "dompurify"; Micra.config({ sanitize: DOMPurify.sanitize });Without it,
data-htmlwrites raw HTML as before (still XSS-prone — usedata-textfor untrusted input if you don’t register a sanitizer). -
New exports:
config,MicraConfig.
Changed
- Bundle: ~6.6 → ~6.9 KB gzip (size guard unchanged at 7 KB). Event
modifiers, the path setter/
data-model, and the sanitizer hook share the budget; the.prevent/.stop/.selflogic was de-duplicated into oneapplyModifiershelper in the process.
Migration
- No breaking changes. Existing
@keydown="onKey"+e.keybranching keeps working; the new modifiers are optional sugar. Existing flatdata-modelkeys are unaffected. Only watch the new reserved nameset.
[2.4.0] — 2026-06-14
Added — CSP-safe expression evaluator (works under strict CSP)
- Directive expressions are now parsed and interpreted by a built-in
evaluator — no
new Function, noeval. Micra runs under a strict Content-Security-Policy (default-src 'self', nounsafe-eval): the exact policy used by security-sensitive server-rendered apps (banking, gov, healthcare). Previously any expression beyond a bare property path (count > 0, ternaries, comparisons, method calls) compiled vianew Functionand was blocked under such a CSP. - The build now fails if
eval/new Functionever reappears in the bundle (🔒 CSP guard). - Stronger security model, by construction. Globals like
window,fetch,constructorare unreachable because no scope contains them — not because they’re shadowed. Member access additionally blocks__proto__/constructor/prototype, closing theitem.constructor.constructor("…")()escape the oldwith()-based evaluator left open.
Added — call expressions in @event
-
@eventhandlers accept call expressions with arguments, evaluated against an event scope (the rowiteminsidedata-each,$event/event, and component methods):<button @click="select(item.id)">pick</button> <input @input="set($event.target.value)" />Bare method names (
@click="save") work as before.data-onkeeps bare method names only (its handler separator is,).
Changed
- Bundle: ~5.5 KB → ~6.6 KB gzip (size guard raised to 7 KB). The eval-based path was removed, not kept as a fallback, so this is the whole cost of the parser/interpreter. Micra is no longer the very smallest in its class (petite-vue ~6 KB) — the trade is CSP-safety, a stronger security model, and call-args in events.
Fixed
data-eachrow root detection counted whitespace text nodes around a single element, wrapping pretty-printed<tr>rows in<micra-each-item>(invalid inside<tbody>). Carried over from 2.3.2; now also covered by the new evaluator’s tests.
Migration
- No API changes. Existing expressions evaluate identically (full parity
suite). If you relied on an expression feature outside the documented
grammar (assignments,
new, computed[]indexing, arrow functions — none of which were ever recommended in directives), it no longer works; move that logic into a component method.
[2.3.2] — 2026-06-10
Fixed — data-each row root detection
- A pretty-printed template with one root element is no longer wrapped
in
<micra-each-item>. Single-root detection usedfrag.childNodes.length === 1, which counts whitespace text nodes — so<template data-each>\n <tr>…</tr>\n</template>(three child nodes: text, element, text) took the multi-root path and wrapped every row. For table rows this put invalid content inside<tbody>and broketbody > trchild selectors. - Exact semantics of the new check (top-level child nodes only, O(1)-ish
per row): plain whitespace (space,
\t,\n,\f,\r) beside the single element is ignored; NBSP and any other visible character keep the wrapper (they render, so they must survive); comment nodes beside the root are dropped — they don’t render and aren’t worth invalid wrapper content inside<tbody>. - Found by the official
isKeyedcompliance check while preparing the js-framework-benchmark submission — Micra now passes it for run / remove / swap. - Affects both keyed and non-keyed paths (shared
createRowNode). - Internal:
ALLOWED_GLOBALSin the expression evaluator is now built from a split string (identical semantics, smaller minified output). Bundle: 5.5 KB gzip (5632 bytes — exactly at the size guard; the next feature pays for itself or raises the limit consciously).
Internal — LLM-benchmark harness hardening (no library impact)
Post-review fixes to bench-llm/ so published numbers are trustworthy:
windows now close even when an assertion fails (stray timers no longer
misattribute errors to the next generation); errors aggregate across all
pages of multi-scenario tasks; quoted > inside template attributes no
longer mangles pages; ESM micra imports are rewritten to UMD bindings
instead of being dropped; the injected bundle is marked with
data-harness-bundle (single source of truth for loader and lint);
Object.groupBy replaced for Node 20 compatibility; --only no longer
overwrites aggregate results; the @next publish guard distinguishes
“version not published” from registry/network failures.
[2.3.1] — 2026-05-30
Performance
- Batch scheduler now uses
queueMicrotaskinstead ofPromise.resolve().then(...). Each render batch enqueues a single microtask instead of allocating a Promise plus a reaction job, and the flush callback is hoisted out of the hot path so it isn’t re-created on everyschedule()call. Behaviour is identical — same microtask timing, same write-collapsing. No public-API change.
Internal — dead-code removal
- Removed the
src/dom/query.tsmodule (queryAll/queryOwn/queryOwnAll/filterOwn). It had no importers since the 2.2.0 single-pass scan replaced per-renderquerySelectorAllcalls with oneTreeWalkertraversal — esbuild already tree-shook it out of the bundle, so this is a source-only cleanup. - Removed two dead bookkeeping writes:
node.__micraEachandnode.__micraKeywere assigned during list rendering but never read (keys live in the keyed-diffMap; the no-key path doesn’t tag rows). Dropped the matching fields fromMicraElement. - Dropped the unused
instanceparameter fromapplyDirectives— it was never referenced in the body.
Docs
- New Rails + Micra recipe
(
docs/recipes/rails.md+ a site page): manual importmap integration, themicra-railsgem with its caveats, a Tasks board demonstrating SSR props / CSRF-attachedthis.fetch/ cross-component bus, and the Turbo Drive / Streams / Frames mount-and-cleanup story. - README gains a TypeScript section spelling out what’s checked
end-to-end (state, methods, event payloads) versus what isn’t (the
expression strings inside
data-*attributes). - Landing page gains Speed (cross-library benchmark cards) and AI sandboxes (copy-the-LLM-prompt) sections.
Bundle
- 5.5 KB gzip (5582 bytes) — a few bytes lighter than 2.3.0 after the dead-code removal.
[2.3.0] — 2026-05-30
TypeScript — type-safe event bus
-
New augmentable
MicraEventsinterface. Declare your app’s events once andMicra.emit/Micra.on/this.emit/this.onenforce payload types and arity at the call site:declare module "micra.js" { interface MicraEvents { "cart:updated": { count: number }; "modal:close": void; } } Micra.emit("cart:updated", { count: 3 }); // ✓ Micra.emit("cart:updated", { count: "3" }); // ✗ type error Micra.emit("modal:close"); // ✓ void → no args -
Events that are NOT declared in
MicraEventskeep the previous behaviour — payload typed asunknown, optional argument. Untyped code keeps compiling unchanged. -
New exported types:
MicraEvents,EventPayload<K>,EmitArgs<K>. -
Bundle stays at 5.4 KB gzip — types only, no runtime change.
Breaking — types only
- The legacy
on<T>(event, handler)generic now infersTas the event key, not the handler payload. Code that explicitly passed a payload type via the generic (Micra.on<User>('user:updated', h)) still compiles, buth’s parameter falls back tounknownunless the event is declared inMicraEvents. Migration: register the event viadeclare module 'micra.js'and drop the explicit generic. No runtime impact.
Performance — non-keyed data-each now reuses DOM nodes
- Non-keyed
<template data-each>no longer re-renders the whole list on every update. The new path keeps the firstmin(prev, next)row nodes in place — only the length delta is touched (tail removed when the list shrinks, new rows cloned when it grows). Each retained row gets a freshitemStateand a re-applied directive pass through its cached__micraScan, so content updates correctly without the remove/re-clone overhead. - Row identity is now stable across renders for the no-key path: event
listeners bound via
data-on/@event/data-modelsurvive re-renders without re-binding, and DOM-level state (focus, scroll, CSS transitions) is preserved on retained rows. - Items that didn’t change (same reference + same index) skip
applyDirectivesentirely when only thedata-eachsource array is the trigger for this render cycle — samecanSkipUnchangedoptimisation the keyed path already had. - Bundle: 5.5 KB gzip (raised guard from 5.4 → 5.5 to give the
shared row-creation helper room; net code is slightly smaller after
factoring
createRowNodeout of both keyed and non-keyed paths).
Breaking — non-keyed multi-root rows now wrap in <micra-each-item>
- Templates whose
data-eachcontent has more than one top-level node now render each row inside a<micra-each-item style="display:contents">wrapper, mirroring the keyed path’s existing behaviour. The wrapper is visually inert (CSSdisplay:contentsopts out of the box model) but it does add one node to the parse tree. - Impact:
- CSS: child selectors that targeted
parent > .rowwill now matchparent > micra-each-iteminstead. Use descendant selectors (parent .row) or update the rules. - Invalid HTML contexts: templates whose rows are
<tr>/<td>/<li>inside<tbody>/<tr>/<ul>cannot legally have a wrapper between the parent and the row. Hoist the wrapper into the template (so the row is single-rooted) or use adata-key.
- CSS: child selectors that targeted
- Single-root templates are unchanged — by far the common case.
[2.2.1] — 2026-05-28
Performance — batched first list render
- First render of a keyed
data-eachlist now inserts in a single DOM operation.renderKeyedpreviously appended each new row with an individualanchor.after(node)call — N insertions for an N-row list. On the initial render (no previous rows to diff against), all freshly-cloned rows are now collected into oneDocumentFragmentand inserted with a singlemarker.after(), skipping the LIS reorder pass entirely. The update, swap, and reorder paths are unchanged. - No public-API change. Bundle stays at 5.4 KB gzip.
[2.2.0] — 2026-05-27
Performance — single-pass DOM scan
-
Mount cost cut roughly in half. Internal
applyDirectives,bindDataOn,bindAtEvents,bindModels,collectRefs, andrenderListused to walk the DOM 10+ times per render via separatequerySelectorAllcalls. They now consume a single pre-computedScanIndexbuilt by oneTreeWalkertraversal. The walkerFILTER_REJECTs subtrees rooted at nested[data-component]— those subtrees aren’t even visited. -
Cross-library benchmark numbers on Firefox 150 / Mac (median of 7 runs):
Scenario Before After Vs Alpine.js Vs petite-vue Mount 100 components 10.8 ms 5.6 ms × 4.9 faster × 3.6 faster Mount 1000 components 128.3 ms 65.4 ms × 7.0 faster × 2.4 faster Update 5 of 1000 rows — 1 ms × 886 faster × 1002 faster 10,000 state writes — 1 ms × 980 faster × 983 faster First render 1000 keyed — 12 ms × 79 faster × 82 faster Swap first ↔ last of 1000 — 7 ms × 131 faster × 143 faster Bundle stays at 5.0 KB gzip — the rewrite removed code, not added it.
TypeScript — full inference from your component literal
-
Method-level type inference. Both
S(state shape) andM(method set) are now inferred from the object literal passed toMicra.define/Micra.mount. Inside method bodies and lifecycle hooks boththis.state.Xandthis.someMethod()are fully typed:Micra.define("counter", { state: { count: 0 }, inc() { this.state.count++; // ✓ number this.dec(); // ✓ inferred sibling method // this.foo() // ❌ Property 'foo' does not exist }, dec() { this.state.count--; }, }); -
Public
ComponentInstance<S, M>andComponentDefinition<S, M>now take a second generic parameter for methods.mount()returns a fully typed instance —inst.inc()andinst.state.countare both checked at the call site. -
New
ComponentMethodsandComponentBuiltinstypes exported for advanced typing.
Breaking — internal only
- The internal directive scan format changed (
DirectiveCache→ScanIndex). Internal-only — no public-API change. If you reached into internals via deep imports, switch to consumingel.__micraScaninstead ofel.__micraCache.
[2.1.0] — 2026-05-25
Added
this.fetch(url, { signal })now forwardsAbortSignalto the nativefetch(). Previously thesignaloption was treated as any other GET-option and serialized into the URL as&signal=[object AbortSignal], while never reaching the underlying request — so abort silently did nothing. After this release:signalpasses through verbatim to nativefetch().signalis excluded from the GET-querystring serialization loop.AbortController#abort()rejects the in-flight request with anAbortError, matching native semantics.- Enables the canonical search-debounce pattern (drop a stale request when
a fresher query arrives) without dropping to native
fetchmanually. - Migration: none — purely additive, the previous URL-serialization behaviour was a bug.
Tests
- 76 new tests for the components and recipes shipped on the docs site (14 components + 6 recipes). Total suite: 235 tests across 13 files.
[2.0.0] — 2026-05-24
Breaking
data-ifnow truly unmounts the element from the DOM. Previouslydata-ifanddata-showwere aliases — both toggledstyle.display. Nowdata-ifdetaches the element (replacing it with a Comment placeholder) when falsy and re-inserts it when truthy.data-showkeeps the oldstyle.displaybehaviour and is the way to express cheap visibility toggling.- Side effect:
this.refs.Xisundefinedwhile the element is detached. - DOM listeners on the detached node survive — re-insert preserves identity.
<template data-each>inside adata-if=falsesubtree is suspended and re-renders cleanly when the ancestor returns.- Migration: if you relied on
data-ifkeeping the element in the DOM (e.g. you were readingthis.refs.Xwhile hidden, or animatingdisplaytransitions), replace thosedata-ifattributes withdata-show.
- Side effect:
Fixed
@eventshorthand no longer crosses nesteddata-componentboundaries.bindAtEventspreviously walked all descendants viaqueryAll('*'), attaching parent-component handlers to elements owned by a nested child component. It now usesqueryOwnAlllikedata-on/data-modelalready do.this.fetch(url, { method: 'POST' })without abodyno longer sends the options object as the body. Previouslybodywas set toJSON.stringify(options)(which serialized{"method":"POST"}to the server). Now the body is omitted unlessoptions.bodyis provided.
Added
queryOwnAll(root, selector)insrc/dom/query.ts— selector variant ofqueryOwnfor cases where there is no attribute to query by (e.g. scanning*for@-prefixed attribute names).- Recipe:
docs/recipes/sse.md— server-sent events pattern usingonCreate+ nativeEventSource+onDestroycleanup. No new library surface; just the canonical pattern for live data on top of Micra.
Docs
docs/directives.md— full split betweendata-if(unmount) anddata-show(display).docs/llm-guide.md,PROMPT.md,llms.txt,llms-full.txt— updated the directive table and added a “when to pick which” rule for AI agents.
[1.1.0] — 2026-05-24
Security
- Directive expressions now shadow non-whitelisted globals. Identifiers in
data-text,data-if,data-bind, etc. resolve to state keys, instance methods, or one of the whitelisted globals:Math,JSON,Date,String,Number,Boolean,Array,Object,parseInt,parseFloat,isNaN,isFinite,NaN,Infinity,undefined. Everything else (window,document,fetch,eval,setTimeout,constructor,__proto__, …) resolves toundefined. This blocks the commonconstructor.constructor("...")()chain and accidental access to browser globals from directive markup. Seedocs/directives.md → Security modelfor the full contract. data-htmlis now explicitly documented as XSS-prone. Inline JSDoc warning + Security model section in the docs. Sanitize untrusted input on the server before binding.
Fixed
destroy()actually unmounts. Every DOM listener attached bydata-on/@event/data-modelis now tracked on the instance and removed indestroy(). Scheduled re-renders after destroy are no-ops. Per-element bookkeeping flags are cleared so re-mounting the same DOM rebinds cleanly.- Instance methods called from directive expressions now have
thisbound to the component.data-text="doneCount() + ' done'"wheredoneCountreadsthis.state.itemsnow works as written (previously silently returnedundefineddue towith()semantics). data-modelon focused inputs syncs programmatic state changes.this.state.q = ''while the input has focus now clears the field. Live typing remains a no-op (state already matches value after the input event, so no write happens).data-modelon<input type="number">/<input type="range">writes anumber, not a string. Empty inputs writenull. Checkbox inputs continue to write booleans.- Duplicate
data-eachkeys produce a warning. Previously rows silently collided. null/undefineddata-eachkeys warn once per render instead of once per item.@eventshorthand re-scans the subtree on every render. Replaces the root-level__micraAtScannedflag with per-element__micraAtBound, so@clickattributes inside markup injected viadata-htmlget bound on the next render.data-bind="class:..."+data-classon the same element now warns viavalidateDirectives— the two directives fight on every render.bus.off()cleans up empty event Sets instead of leaving them in the map.
Added
- Dev warnings (deduped):
- re-entrant
render()call (typically: a directive expression that mutated state) — warns once per instance. - runtime errors in directive expressions — warns once per unique expression string.
- re-entrant
Changed
- Internal:
data-bindanddata-classspecs are pre-parsed once intoCachedPairBinding.pairsinDirectiveCache— re-renders skip the comma+colon split. - Internal:
Object.prototypekey membership is pre-computed once at module load and cached in aSet(fastersafeStateHas). - Internal:
data-eachno-key warning is deduped per template via__micraNoKeyWarned.
Docs
docs/directives.md— new “Security model” section.docs/llm-guide.md— Security model,Micra.offreference, “Things Micra does NOT support” (key modifiers, nesteddata-model,data-ifkeeps element in DOM).docs/examples.md— inline-edit example now hasdata-ref="input"and usese.key === 'Enter'instead of unsupported@keydown.enter.
Bundle size
- ~3.7 KB → 4.8 KB gzip. Cost of the security hardening + listener cleanup tracking.
Migration notes
- If any directive expression relied on
constructor,window,fetch, or other non-whitelisted globals, it now resolves toundefined. Move the access into a component method. - If you held onto an instance after calling
destroy(), you can now safely re-mount the same DOM with a new definition.
[1.0.0] — initial release
Reactive shallow Proxy state, batched microtask rendering, DOM directives
(data-text, data-html, data-if, data-show, data-bind, data-model,
data-class, data-on, @event), keyed data-each list rendering, event
bus, SSR prop(), fetch() helper, idempotent start().