micra.js 2.3.0 → 2.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,88 @@ All notable changes to Micra.js will be documented in this file. Format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), versioning follows
5
5
  [SemVer](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [2.3.2] — 2026-06-10
8
+
9
+ ### Fixed — `data-each` row root detection
10
+
11
+ - **A pretty-printed template with one root element is no longer wrapped
12
+ in `<micra-each-item>`.** Single-root detection used
13
+ `frag.childNodes.length === 1`, which counts whitespace text nodes — so
14
+ `<template data-each>\n <tr>…</tr>\n</template>` (three child nodes:
15
+ text, element, text) took the multi-root path and wrapped every row.
16
+ For table rows this put invalid content inside `<tbody>` and broke
17
+ `tbody > tr` child selectors.
18
+ - Exact semantics of the new check (top-level child nodes only, O(1)-ish
19
+ per row): plain whitespace (space, `\t`, `\n`, `\f`, `\r`) beside the
20
+ single element is ignored; **NBSP and any other visible character keep
21
+ the wrapper** (they render, so they must survive); **comment nodes
22
+ beside the root are dropped** — they don't render and aren't worth
23
+ invalid wrapper content inside `<tbody>`.
24
+ - Found by the official `isKeyed` compliance check while preparing the
25
+ [js-framework-benchmark](https://github.com/krausest/js-framework-benchmark)
26
+ submission — Micra now passes it for run / remove / swap.
27
+ - Affects both keyed and non-keyed paths (shared `createRowNode`).
28
+ - Internal: `ALLOWED_GLOBALS` in the expression evaluator is now built
29
+ from a split string (identical semantics, smaller minified output).
30
+ Bundle: **5.5 KB gzip** (5632 bytes — exactly at the size guard; the
31
+ next feature pays for itself or raises the limit consciously).
32
+
33
+ ### Internal — LLM-benchmark harness hardening (no library impact)
34
+
35
+ Post-review fixes to `bench-llm/` so published numbers are trustworthy:
36
+ windows now close even when an assertion fails (stray timers no longer
37
+ misattribute errors to the next generation); errors aggregate across all
38
+ pages of multi-scenario tasks; quoted `>` inside template attributes no
39
+ longer mangles pages; ESM micra imports are rewritten to UMD bindings
40
+ instead of being dropped; the injected bundle is marked with
41
+ `data-harness-bundle` (single source of truth for loader and lint);
42
+ `Object.groupBy` replaced for Node 20 compatibility; `--only` no longer
43
+ overwrites aggregate results; the `@next` publish guard distinguishes
44
+ "version not published" from registry/network failures.
45
+
46
+ ## [2.3.1] — 2026-05-30
47
+
48
+ ### Performance
49
+
50
+ - **Batch scheduler now uses `queueMicrotask` instead of
51
+ `Promise.resolve().then(...)`.** Each render batch enqueues a single
52
+ microtask instead of allocating a Promise plus a reaction job, and the
53
+ flush callback is hoisted out of the hot path so it isn't re-created on
54
+ every `schedule()` call. Behaviour is identical — same microtask timing,
55
+ same write-collapsing. No public-API change.
56
+
57
+ ### Internal — dead-code removal
58
+
59
+ - Removed the `src/dom/query.ts` module (`queryAll` / `queryOwn` /
60
+ `queryOwnAll` / `filterOwn`). It had no importers since the 2.2.0
61
+ single-pass scan replaced per-render `querySelectorAll` calls with one
62
+ `TreeWalker` traversal — esbuild already tree-shook it out of the
63
+ bundle, so this is a source-only cleanup.
64
+ - Removed two dead bookkeeping writes: `node.__micraEach` and
65
+ `node.__micraKey` were assigned during list rendering but never read
66
+ (keys live in the keyed-diff `Map`; the no-key path doesn't tag rows).
67
+ Dropped the matching fields from `MicraElement`.
68
+ - Dropped the unused `instance` parameter from `applyDirectives` — it was
69
+ never referenced in the body.
70
+
71
+ ### Docs
72
+
73
+ - New [Rails + Micra recipe](https://github.com/denisfl/micra.js/blob/master/docs/recipes/rails.md)
74
+ (`docs/recipes/rails.md` + a site page): manual importmap integration,
75
+ the `micra-rails` gem with its caveats, a Tasks board demonstrating SSR
76
+ props / CSRF-attached `this.fetch` / cross-component bus, and the Turbo
77
+ Drive / Streams / Frames mount-and-cleanup story.
78
+ - README gains a **TypeScript** section spelling out what's checked
79
+ end-to-end (state, methods, event payloads) versus what isn't (the
80
+ expression strings inside `data-*` attributes).
81
+ - Landing page gains **Speed** (cross-library benchmark cards) and **AI
82
+ sandboxes** (copy-the-LLM-prompt) sections.
83
+
84
+ ### Bundle
85
+
86
+ - **5.5 KB gzip** (5582 bytes) — a few bytes lighter than 2.3.0 after the
87
+ dead-code removal.
88
+
7
89
  ## [2.3.0] — 2026-05-30
8
90
 
9
91
  ### TypeScript — type-safe event bus
package/README.md CHANGED
@@ -1,7 +1,24 @@
1
1
  # Micra.js
2
2
 
3
+ [![CI](https://github.com/denisfl/micra.js/actions/workflows/ci.yml/badge.svg)](https://github.com/denisfl/micra.js/actions/workflows/ci.yml)
4
+ [![npm version](https://img.shields.io/npm/v/micra.js)](https://www.npmjs.com/package/micra.js)
5
+ [![bundle size](https://img.shields.io/bundlephobia/minzip/micra.js?label=gzip)](https://bundlephobia.com/package/micra.js)
6
+ [![types included](https://img.shields.io/badge/types-included-blue)](./dist/index.d.ts)
7
+ [![license MIT](https://img.shields.io/npm/l/micra.js)](./LICENSE)
8
+
3
9
  Micra.js is a lightweight reactive TypeScript framework for small sites and SaaS apps. It gives you reactive state, DOM directives, keyed list rendering, an event bus, SSR-friendly props, and auto-mounting in about 5 KB gzip.
4
10
 
11
+ ## Project status
12
+
13
+ - **Stable, SemVer-disciplined.** Breaking changes only in majors; every
14
+ release documented in [CHANGELOG.md](./CHANGELOG.md) with migration notes.
15
+ - **Tested.** 255 tests across 14 suites run on every push and before every
16
+ npm publish; the build fails if the bundle exceeds **5.5 KB gzip**.
17
+ - **Typed.** Ships its own `.d.ts` — state, methods, and event-bus payloads
18
+ are checked end-to-end (see [TypeScript](#typescript)).
19
+ - **Security policy.** See [SECURITY.md](./SECURITY.md) — private reporting,
20
+ 72-hour acknowledgement, supported-versions table.
21
+
5
22
  ## When to use Micra.js
6
23
 
7
24
  Built for **server-rendered apps** (Rails, Laravel, Django, Phoenix, ASP.NET) and small SaaS frontends that need a sprinkle of reactivity without a build step.
@@ -71,6 +88,48 @@ npm install micra.js
71
88
  import * as Micra from "micra.js";
72
89
  ```
73
90
 
91
+ ### TypeScript
92
+
93
+ The npm package ships its own `dist/index.d.ts` — no `@types/micra.js` package
94
+ needed. Inside every method body and lifecycle hook, both `this.state.X` and
95
+ `this.someMethod()` are fully checked at the call site (both `state` and the
96
+ method set are inferred from the literal you pass to `Micra.define`).
97
+
98
+ ```ts
99
+ import * as Micra from "micra.js";
100
+
101
+ Micra.define("counter", {
102
+ state: { count: 0 },
103
+ inc() {
104
+ this.state.count++; // ✓ number
105
+ this.dec(); // ✓ inferred sibling method
106
+ // this.foo(); // ✗ Property 'foo' does not exist
107
+ },
108
+ dec() { this.state.count--; },
109
+ });
110
+
111
+ // Type-safe event bus via declaration merging
112
+ declare module "micra.js" {
113
+ interface MicraEvents {
114
+ "cart:updated": { count: number };
115
+ "modal:close": void;
116
+ }
117
+ }
118
+
119
+ Micra.emit("cart:updated", { count: 3 }); // ✓
120
+ Micra.emit("cart:updated", { count: "3" }); // ✗ type error
121
+ Micra.emit("modal:close"); // ✓ void → no args
122
+ ```
123
+
124
+ **What's checked:** imports, state shape, method names, event-bus payloads,
125
+ lifecycle hooks, refs, `Micra.mount()` return type.
126
+
127
+ **What's not:** the expression strings inside `data-text="…"` / `@click="…"`
128
+ attributes — those are plain HTML to the IDE and validated only at mount
129
+ time. Same trade-off as Alpine.js `x-*` and petite-vue `v-*`; the
130
+ alternatives are JSX or a single-file-component compiler, neither of which
131
+ Micra ships.
132
+
74
133
  ## Basic usage
75
134
 
76
135
  A counter mounted automatically from `data-component`:
@@ -183,6 +242,7 @@ this.on(event, handler)
183
242
  - [Todo app](./docs/recipes/todo-app.md)
184
243
  - [Server-sent events (SSE)](./docs/recipes/sse.md)
185
244
  - [htmx bridge](./docs/recipes/htmx.md)
245
+ - [Rails + Micra](./docs/recipes/rails.md)
186
246
 
187
247
  ## Code generation with LLMs
188
248
 
@@ -23,6 +23,10 @@ export declare function createReactiveState<S extends StateRecord>(obj: S, sched
23
23
  * Return a debounce function that defers `render` to the next microtask.
24
24
  * Multiple calls within the same tick collapse to a single render.
25
25
  *
26
+ * Uses `queueMicrotask` so each batch enqueues a single microtask instead of
27
+ * allocating a Promise + reaction job. `flush` is hoisted out of the hot path
28
+ * so it isn't re-created on every schedule() call.
29
+ *
26
30
  * @example
27
31
  * const schedule = createScheduler(render)
28
32
  * schedule() // defers render
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Important: this module does NOT handle data-each — see dom/each.ts.
13
13
  */
14
- import type { InternalInstance, ScanIndex, StateRecord } from '../types';
14
+ import type { ScanIndex, StateRecord } from '../types';
15
15
  import { warn } from '../utils/expr';
16
16
  /**
17
17
  * Apply all non-each directives to a component subtree.
@@ -23,7 +23,7 @@ import { warn } from '../utils/expr';
23
23
  * @param state - Expression state (may include item/index for each rows)
24
24
  * @param rawState - Raw (non-proxy) state for model sync
25
25
  */
26
- export declare function applyDirectives<S extends StateRecord>(scan: ScanIndex, state: StateRecord, rawState: StateRecord, _instance: InternalInstance<S>): void;
26
+ export declare function applyDirectives(scan: ScanIndex, state: StateRecord, rawState: StateRecord): void;
27
27
  /**
28
28
  * Validate directive usage and emit dev warnings.
29
29
  * Called once after the initial render of a component, with the already-built
@@ -5,9 +5,9 @@
5
5
  * traversal that classifies every directive attribute in a single visit.
6
6
  *
7
7
  * Boundaries:
8
- * - REJECT (skip subtree) on nested [data-component] — same semantics as
9
- * the old `filterOwn` helper, but applied during the walk so we don't
10
- * even *visit* those nodes.
8
+ * - REJECT (skip subtree) on nested [data-component] — a parent component
9
+ * never processes directives owned by a nested child. Applied during the
10
+ * walk so we don't even *visit* those nodes.
11
11
  * - <template> contents are not visited (browser TreeWalker default).
12
12
  * `<template data-each>` itself IS visited and classified into scan.each;
13
13
  * its children are processed by each.ts on every render — fresh rows
package/dist/micra.cjs.js CHANGED
@@ -1,4 +1,4 @@
1
- /* Micra.js v2.3.0 — https://github.com/micra-js/micra — MIT */
1
+ /* Micra.js v2.3.2 — https://github.com/micra-js/micra — MIT */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -119,23 +119,9 @@ function debug() {
119
119
  var exprCache = /* @__PURE__ */ new Map();
120
120
  var warnedRuntime = /* @__PURE__ */ new Set();
121
121
  var SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/;
122
- var ALLOWED_GLOBALS = /* @__PURE__ */ new Set([
123
- "Math",
124
- "JSON",
125
- "Date",
126
- "String",
127
- "Number",
128
- "Boolean",
129
- "Array",
130
- "Object",
131
- "parseInt",
132
- "parseFloat",
133
- "isNaN",
134
- "isFinite",
135
- "NaN",
136
- "Infinity",
137
- "undefined"
138
- ]);
122
+ var ALLOWED_GLOBALS = new Set(
123
+ "Math,JSON,Date,String,Number,Boolean,Array,Object,parseInt,parseFloat,isNaN,isFinite,NaN,Infinity,undefined".split(",")
124
+ );
139
125
  var PARAM_S = "$s";
140
126
  var PARAM_SAFE = "$safe";
141
127
  var SAFE_OUTER = new Proxy(/* @__PURE__ */ Object.create(null), {
@@ -253,13 +239,14 @@ function createReactiveState(obj, schedule, onKey) {
253
239
  }
254
240
  function createScheduler(render) {
255
241
  let pending = false;
242
+ const flush = () => {
243
+ pending = false;
244
+ render();
245
+ };
256
246
  return function schedule() {
257
247
  if (pending) return;
258
248
  pending = true;
259
- Promise.resolve().then(() => {
260
- pending = false;
261
- render();
262
- });
249
+ queueMicrotask(flush);
263
250
  };
264
251
  }
265
252
 
@@ -325,7 +312,7 @@ function applyModel(el, key, rawState) {
325
312
  const desired = stateVal == null ? "" : String(stateVal);
326
313
  if (html.value !== desired) html.value = desired;
327
314
  }
328
- function applyDirectives(scan, state, rawState, _instance) {
315
+ function applyDirectives(scan, state, rawState) {
329
316
  for (const b of scan.if) applyIf(b, state);
330
317
  for (const b of scan.text) applyText(b.el, b.expr, state);
331
318
  for (const b of scan.html) applyHtml(b.el, b.expr, state);
@@ -573,8 +560,13 @@ function renderList(templates, state, rawState, instance, triggerKey) {
573
560
  function createRowNode(tmpl, state, instance) {
574
561
  const frag = tmpl.content.cloneNode(true);
575
562
  let node;
576
- if (frag.childNodes.length === 1) {
577
- node = frag.firstElementChild;
563
+ const first = frag.firstElementChild;
564
+ const single = !!first && !first.nextElementSibling && !Array.prototype.some.call(
565
+ frag.childNodes,
566
+ (c) => c.nodeType === 3 && /[^\x00- ]/.test(c.textContent)
567
+ );
568
+ if (single) {
569
+ node = first;
578
570
  } else {
579
571
  node = document.createElement("micra-each-item");
580
572
  node.style.display = "contents";
@@ -608,7 +600,6 @@ function renderKeyed(tmpl, items, keyAttr, marker, keyMap, state, rawState, inst
608
600
  let node = keyMap.get(key);
609
601
  if (!node) {
610
602
  node = createRowNode(tmpl, state, instance);
611
- node.__micraKey = key;
612
603
  keyMap.set(key, node);
613
604
  } else if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === index) {
614
605
  nextNodes.push(node);
@@ -621,7 +612,7 @@ function renderKeyed(tmpl, items, keyAttr, marker, keyMap, state, rawState, inst
621
612
  itemState.index = index;
622
613
  itemState.$index = index;
623
614
  const rowScan = (_a = node.__micraScan) != null ? _a : node.__micraScan = scanComponent(node);
624
- applyDirectives(rowScan, itemState, rawState, instance);
615
+ applyDirectives(rowScan, itemState, rawState);
625
616
  nextNodes.push(node);
626
617
  }
627
618
  for (const [key, node] of keyMap) {
@@ -706,7 +697,7 @@ function renderNoKey(tmpl, items, marker, state, rawState, instance, canSkipUnch
706
697
  itemState.item = item;
707
698
  itemState.index = i;
708
699
  itemState.$index = i;
709
- applyDirectives(node.__micraScan, itemState, rawState, instance);
700
+ applyDirectives(node.__micraScan, itemState, rawState);
710
701
  nextList[i] = node;
711
702
  }
712
703
  for (let i = nextLen; i < prevLen; i++) {
@@ -721,10 +712,9 @@ function renderNoKey(tmpl, items, marker, state, rawState, instance, canSkipUnch
721
712
  itemState.item = item;
722
713
  itemState.index = i;
723
714
  itemState.$index = i;
724
- node.__micraEach = true;
725
715
  node.__micraItem = item;
726
716
  node.__micraIndex = i;
727
- applyDirectives(node.__micraScan, itemState, rawState, instance);
717
+ applyDirectives(node.__micraScan, itemState, rawState);
728
718
  nextList[i] = node;
729
719
  frag.append(node);
730
720
  }
@@ -823,7 +813,7 @@ function mount(selector, definition) {
823
813
  try {
824
814
  const mRoot2 = root;
825
815
  const scan = (_a2 = mRoot2.__micraScan) != null ? _a2 : mRoot2.__micraScan = scanComponent(root);
826
- applyDirectives(scan, exprState, rawState, instance);
816
+ applyDirectives(scan, exprState, rawState);
827
817
  renderList(scan.each, exprState, rawState, instance, triggerKey);
828
818
  bindDataOn(scan.on, instance);
829
819
  bindAtEvents(scan.atEvents, instance);