micra.js 2.2.0 → 2.3.0

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/llms-full.txt CHANGED
@@ -7,7 +7,7 @@ This file follows the llmstxt.org "expanded" convention: it inlines code recipes
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npm install micra.js@^2.2.0
10
+ npm install micra.js@^2.3.0
11
11
  ```
12
12
 
13
13
  ```ts
@@ -17,7 +17,7 @@ import * as Micra from 'micra.js'
17
17
  Or CDN (no build step):
18
18
 
19
19
  ```html
20
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
20
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
21
21
  ```
22
22
 
23
23
  This exposes a global `Micra` object.
@@ -146,7 +146,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
146
146
  <button @click="inc">+</button>
147
147
  </div>
148
148
 
149
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
149
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
150
150
  <script>
151
151
  Micra.define('counter', {
152
152
  state: { count: 0 },
@@ -190,7 +190,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
190
190
  </footer>
191
191
  </div>
192
192
 
193
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
193
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
194
194
  <script>
195
195
  Micra.define('todo-app', {
196
196
  state: {
@@ -262,7 +262,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
262
262
  <p data-if="filtered().length === 0">No matches.</p>
263
263
  </div>
264
264
 
265
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
265
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
266
266
  <script>
267
267
  Micra.define('users-table', {
268
268
  state: {
@@ -303,7 +303,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
303
303
  <p data-if="success">Invitation sent ✓</p>
304
304
  </form>
305
305
 
306
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
306
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
307
307
  <script>
308
308
  Micra.define('invite-form', {
309
309
  state: { email: '', loading: false, error: '', success: false },
@@ -345,7 +345,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
345
345
  </div>
346
346
  </div>
347
347
 
348
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
348
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
349
349
  <script>
350
350
  Micra.define('open-modal-btn', {
351
351
  open() {
@@ -394,7 +394,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
394
394
  <section data-if="tab === 'security'">Security content</section>
395
395
  </div>
396
396
 
397
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
397
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
398
398
  <script>
399
399
  Micra.define('tabs', {
400
400
  state: { tab: 'overview' },
@@ -414,7 +414,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
414
414
  <button @click="upgrade" data-if="plan !== 'enterprise'">Upgrade</button>
415
415
  </div>
416
416
 
417
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
417
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
418
418
  <script>
419
419
  Micra.define('user-card', {
420
420
  state: { name: '', plan: '' },
@@ -445,7 +445,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
445
445
  <p data-if="!loading && results.length === 0 && query">No results.</p>
446
446
  </div>
447
447
 
448
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
448
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
449
449
  <script>
450
450
  Micra.define('search', {
451
451
  state: { query: '', results: [], loading: false },
@@ -479,7 +479,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
479
479
  <p data-if="loading">Loading chart…</p>
480
480
  </div>
481
481
 
482
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
482
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
483
483
  <script>
484
484
  Micra.define('revenue-chart', {
485
485
  state: { loading: true },
@@ -511,7 +511,7 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
511
511
  <p data-if="!loading && rows.length === 0">No results.</p>
512
512
  </div>
513
513
 
514
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
514
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
515
515
  <script>
516
516
  Micra.define('search-bar', {
517
517
  state: { query: '' },
@@ -533,6 +533,59 @@ Everything else (`window`, `document`, `fetch`, `eval`, `constructor`, `setTimeo
533
533
  </script>
534
534
  ```
535
535
 
536
+ ## Recipe 11 — htmx bridge (server-driven HTML swaps + Micra islands)
537
+
538
+ Wire htmx for server-driven DOM swaps and Micra for local reactivity on
539
+ the same page. Twelve lines of glue, written once.
540
+
541
+ ```html
542
+ <main hx-get="/page/home" hx-trigger="load" hx-swap="innerHTML"></main>
543
+
544
+ <script src="https://cdn.jsdelivr.net/npm/htmx.org@2/dist/htmx.min.js"></script>
545
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
546
+ <script>
547
+ Micra.define('counter', {
548
+ state: { count: 0 },
549
+ inc() { this.state.count++ },
550
+ })
551
+
552
+ Micra.start() // initial mount
553
+
554
+ // Mount new [data-component] arriving via htmx swap.
555
+ document.body.addEventListener('htmx:afterSettle', (e) => {
556
+ Micra.start(e.target)
557
+ })
558
+
559
+ // Destroy Micra instances inside HTML about to be replaced.
560
+ document.body.addEventListener('htmx:beforeSwap', (e) => {
561
+ Micra.instances().forEach((inst, root) => {
562
+ if (e.target.contains(root)) inst.destroy()
563
+ })
564
+ })
565
+
566
+ // Bridge server-sent HX-Trigger events into the Micra bus.
567
+ document.body.addEventListener('htmx:trigger', (e) => {
568
+ const d = e.detail
569
+ if (typeof d === 'string') return Micra.emit(d)
570
+ for (const [k, v] of Object.entries(d)) Micra.emit(k, v)
571
+ })
572
+ </script>
573
+ ```
574
+
575
+ Rules of thumb:
576
+
577
+ - **Never put `hx-swap` directly on a `[data-component]` element that
578
+ swaps its own `innerHTML`** — the cached directive scan points at gone
579
+ DOM. Use a wrapper as the swap target.
580
+ - **`Micra.start()` is idempotent**, so re-scanning a subtree that
581
+ contains already-mounted siblings is safe.
582
+ - **Always scope to `e.target`** in the bridge, not `document` — scanning
583
+ the whole page on every swap is wasteful.
584
+
585
+ Full reference with state-survival patterns, `hx-vals`/`hx-include`
586
+ bridging, and per-component loading state lives in
587
+ `docs/recipes/htmx.md`.
588
+
536
589
  ---
537
590
 
538
591
  # Anti-pattern reference (what LLMs gravitate to — DO NOT)
@@ -569,9 +622,9 @@ import { ref, computed } from 'vue'
569
622
  import Alpine from 'alpinejs'
570
623
 
571
624
  // ❌ unpkg CDN — blocked by Claude artifacts and most AI sandbox CSPs
572
- <script src="https://unpkg.com/micra.js@2.2.0/dist/micra.min.js"></script>
625
+ <script src="https://unpkg.com/micra.js@2.3.0/dist/micra.min.js"></script>
573
626
  // ✅ Use jsDelivr instead — it auto-mirrors npm and is CSP-allowlisted everywhere
574
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
627
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
575
628
  ```
576
629
 
577
630
  # Final checklist
package/llms.txt CHANGED
@@ -25,7 +25,7 @@ import * as Micra from 'micra.js'
25
25
  Or via CDN (no build step):
26
26
 
27
27
  ```html
28
- <script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.0/dist/micra.min.js"></script>
28
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.0/dist/micra.min.js"></script>
29
29
  ```
30
30
 
31
31
  This exposes a global `Micra` object.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "micra.js",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "Lightweight reactive UI framework for server-rendered pages — reactive state, directives, event bus. < 5 KB gzip.",
5
5
  "type": "module",
6
6
  "main": "./dist/micra.cjs.js",
@@ -25,7 +25,7 @@
25
25
  ],
26
26
  "scripts": {
27
27
  "build": "node build.mjs",
28
- "typecheck": "tsc --noEmit",
28
+ "typecheck": "tsc --noEmit && tsc --noEmit -p tests/tsconfig.json",
29
29
  "dev": "node build.mjs --watch",
30
30
  "test": "vitest run",
31
31
  "test:watch": "vitest",
package/src/core/bus.ts CHANGED
@@ -11,41 +11,50 @@
11
11
  * the unsub token in `instance.__micraSubs` for cleanup on destroy().
12
12
  */
13
13
 
14
- import type { EventHandler, UnsubFn } from '../types'
14
+ import type { EmitArgs, EventHandler, EventPayload, UnsubFn } from '../types'
15
15
 
16
16
  // Module-level bus state — one bus per page load.
17
17
  const _bus = new Map<string, Set<EventHandler>>()
18
18
 
19
19
  /**
20
20
  * Subscribe to a named event. Returns an unsubscribe function.
21
+ * Payload is typed via the `MicraEvents` interface (augmentable).
21
22
  *
22
23
  * @example
23
24
  * const unsub = on('user:login', (user) => console.log(user))
24
25
  * unsub() // stop listening
25
26
  */
26
- export function on<T = unknown>(event: string, handler: EventHandler<T>): UnsubFn {
27
+ export function on<K extends string>(
28
+ event: K,
29
+ handler: (payload: EventPayload<K>) => void,
30
+ ): UnsubFn {
27
31
  if (!_bus.has(event)) _bus.set(event, new Set())
28
32
  _bus.get(event)!.add(handler as EventHandler)
29
- return () => off(event, handler as EventHandler)
33
+ return () => off(event, handler)
30
34
  }
31
35
 
32
36
  /**
33
37
  * Unsubscribe a specific handler from an event.
34
38
  */
35
- export function off(event: string, handler: EventHandler): void {
39
+ export function off<K extends string>(
40
+ event: K,
41
+ handler: (payload: EventPayload<K>) => void,
42
+ ): void {
36
43
  const set = _bus.get(event)
37
44
  if (!set) return
38
- set.delete(handler)
45
+ set.delete(handler as EventHandler)
39
46
  if (set.size === 0) _bus.delete(event)
40
47
  }
41
48
 
42
49
  /**
43
50
  * Publish an event to all subscribers. Errors are caught per-handler.
51
+ * Payload is typed via the `MicraEvents` interface (augmentable).
44
52
  *
45
53
  * @example
46
54
  * emit('user:updated', { id: 1, name: 'Alice' })
47
55
  */
48
- export function emit(event: string, payload?: unknown): void {
56
+ export function emit<K extends string>(event: K, ...args: EmitArgs<K>): void {
57
+ const payload = args[0]
49
58
  _bus.get(event)?.forEach(h => {
50
59
  try { h(payload) } catch (e) { console.error(`[Micra] bus error [${event}]:`, e) }
51
60
  })
package/src/core/mount.ts CHANGED
@@ -17,8 +17,10 @@ import type {
17
17
  ComponentInstance,
18
18
  ComponentMethods,
19
19
  EventHandler,
20
+ EventPayload,
20
21
  InternalInstance,
21
22
  MicraElement,
23
+
22
24
  StateRecord,
23
25
  UnsubFn,
24
26
  } from "../types";
@@ -95,11 +97,11 @@ export function mount<S extends StateRecord, M>(
95
97
  instance.fetch = micraFetch;
96
98
  instance.emit = busEmit;
97
99
 
98
- instance.on = <T = unknown>(
99
- event: string,
100
- handler: EventHandler<T>,
100
+ instance.on = <K extends string>(
101
+ event: K,
102
+ handler: (payload: EventPayload<K>) => void,
101
103
  ): UnsubFn => {
102
- const unsub = busOn(event, handler);
104
+ const unsub = busOn(event, handler as EventHandler);
103
105
  if (!instance.__micraSubs) instance.__micraSubs = [];
104
106
  instance.__micraSubs.push(unsub);
105
107
  return unsub;
@@ -107,8 +109,14 @@ export function mount<S extends StateRecord, M>(
107
109
 
108
110
  // ── Render ────────────────────────────────────────────────────────────────
109
111
  let isRendering = false;
112
+ // Track which state key triggered the current render cycle.
113
+ // 'MULTIPLE' means more than one key was written before the microtask fired.
114
+ let _triggerKey: string | null | "MULTIPLE" = null;
110
115
  const schedule = createScheduler(() => instance.render());
111
- instance.state = createReactiveState(rawState, schedule) as S;
116
+ instance.state = createReactiveState(rawState, schedule, (key) => {
117
+ if (_triggerKey === null) _triggerKey = key;
118
+ else if (_triggerKey !== key) _triggerKey = "MULTIPLE";
119
+ }) as S;
112
120
 
113
121
  // Expression state: proxy that falls back to instance methods so expressions
114
122
  // like `data-text="formatDate(item.date)"` can call component methods.
@@ -149,6 +157,8 @@ export function mount<S extends StateRecord, M>(
149
157
  let warnedReentry = false;
150
158
  instance.render = function () {
151
159
  if (instance.__micraDestroyed) return;
160
+ const triggerKey = _triggerKey;
161
+ _triggerKey = null;
152
162
  if (isRendering) {
153
163
  if (!warnedReentry) {
154
164
  warn(
@@ -166,7 +176,7 @@ export function mount<S extends StateRecord, M>(
166
176
  const scan =
167
177
  mRoot.__micraScan ?? (mRoot.__micraScan = scanComponent(root));
168
178
  applyDirectives(scan, exprState, rawState, instance);
169
- renderList(scan.each, exprState, rawState, instance);
179
+ renderList(scan.each, exprState, rawState, instance, triggerKey);
170
180
  bindDataOn(scan.on, instance);
171
181
  bindAtEvents(scan.atEvents, instance);
172
182
  bindModels(scan.model, instance);
@@ -187,7 +197,7 @@ export function mount<S extends StateRecord, M>(
187
197
  );
188
198
  instance.__micraListeners = [];
189
199
 
190
- // Clear per-element flags & cached directive scan so a future re-mount of the same DOM works.
200
+ // Clear per-element flags & cached scan so a future re-mount of the same DOM works.
191
201
  const clearFlags = (el: Element) => {
192
202
  const m = el as MicraElement;
193
203
  delete m.__micraEvents;
@@ -20,11 +20,12 @@ import type { StateRecord } from '../types'
20
20
  * const state = createReactiveState(raw, render)
21
21
  * state.count = 5 // triggers render() in next microtask
22
22
  */
23
- export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void): S {
23
+ export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void, onKey?: (key: string) => void): S {
24
24
  return new Proxy(obj, {
25
25
  set(target, key: string, value: unknown) {
26
26
  // Cast through StateRecord — TypeScript cannot write through a generic index
27
27
  ;(target as StateRecord)[key] = value
28
+ onKey?.(key)
28
29
  schedule()
29
30
  return true
30
31
  },
@@ -36,7 +36,8 @@ function applyText(el: Element, expr: string, state: StateRecord): void {
36
36
  * for the full security model.
37
37
  */
38
38
  function applyHtml(el: Element, expr: string, state: StateRecord): void {
39
- el.innerHTML = String(evalExpr(expr, state) ?? '')
39
+ const html = String(evalExpr(expr, state) ?? '')
40
+ if (el.innerHTML !== html) el.innerHTML = html
40
41
  }
41
42
 
42
43
  /**
@@ -72,7 +73,9 @@ function applyIf(binding: CachedIfBinding, state: StateRecord): void {
72
73
  * data-show — visibility toggle via `style.display`. Element stays in the DOM.
73
74
  */
74
75
  function applyShow(el: Element, expr: string, state: StateRecord): void {
75
- (el as HTMLElement).style.display = evalExpr(expr, state) ? '' : 'none'
76
+ const desired = evalExpr(expr, state) ? '' : 'none'
77
+ const htmlEl = el as HTMLElement
78
+ if (htmlEl.style.display !== desired) htmlEl.style.display = desired
76
79
  }
77
80
 
78
81
  function applyBind(