micra.js 2.2.1 → 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.1
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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1",
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,6 +17,7 @@ import type {
17
17
  ComponentInstance,
18
18
  ComponentMethods,
19
19
  EventHandler,
20
+ EventPayload,
20
21
  InternalInstance,
21
22
  MicraElement,
22
23
 
@@ -96,11 +97,11 @@ export function mount<S extends StateRecord, M>(
96
97
  instance.fetch = micraFetch;
97
98
  instance.emit = busEmit;
98
99
 
99
- instance.on = <T = unknown>(
100
- event: string,
101
- handler: EventHandler<T>,
100
+ instance.on = <K extends string>(
101
+ event: K,
102
+ handler: (payload: EventPayload<K>) => void,
102
103
  ): UnsubFn => {
103
- const unsub = busOn(event, handler);
104
+ const unsub = busOn(event, handler as EventHandler);
104
105
  if (!instance.__micraSubs) instance.__micraSubs = [];
105
106
  instance.__micraSubs.push(unsub);
106
107
  return unsub;
package/src/dom/each.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  * Responsibilities:
5
5
  * - Process `<template data-each="items" data-key="id">` elements
6
6
  * - Keyed diff: reuse/reorder DOM nodes by key — O(n) with a Map
7
- * - Non-keyed fallback: full replace (no key → warn in dev, full re-render)
7
+ * - Non-keyed fallback: length-based positional reuse min(old, new) rows
8
+ * are kept as-is, the tail is removed or new rows are appended
8
9
  * - Apply directives to each row with a scoped itemState
9
10
  *
10
11
  * LLM NOTE: renderList() is called on every render cycle AFTER applyDirectives().
@@ -12,7 +13,9 @@
12
13
  * Each row node gets its own ScanIndex cached on `node.__micraScan` so
13
14
  * re-renders of that row don't re-walk the DOM.
14
15
  * Keyed mode (data-key present) mutates the DOM in-place — nodes are
15
- * created once and reused. Non-keyed mode removes all nodes and re-clones.
16
+ * created once and reused. Non-keyed mode also reuses existing nodes
17
+ * positionally: only the length delta is touched, the rest gets a fresh
18
+ * itemState and re-applies directives.
16
19
  */
17
20
 
18
21
  import type {
@@ -24,7 +27,7 @@ import type {
24
27
  import { evalExpr, warn } from '../utils/expr'
25
28
  import { applyDirectives } from './directives'
26
29
  import { bindDataOn, bindAtEvents, bindModels } from './events'
27
- import { scanComponent, scanFragment } from './scan'
30
+ import { scanComponent } from './scan'
28
31
 
29
32
  /**
30
33
  * Process all `<template data-each>` elements found by the scanner.
@@ -83,11 +86,42 @@ export function renderList<S extends StateRecord>(
83
86
  if (keyAttr) {
84
87
  renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, state, rawState, instance, canSkipUnchanged)
85
88
  } else {
86
- renderNoKey(tmpl, items as StateRecord[], marker, state, rawState, instance)
89
+ renderNoKey(tmpl, items as StateRecord[], marker, state, rawState, instance, canSkipUnchanged)
87
90
  }
88
91
  }
89
92
  }
90
93
 
94
+ // ── Row node creation (shared by both paths) ──────────────────────────────────
95
+
96
+ /**
97
+ * Clone the template into a fresh row node, wrapping multi-root content in
98
+ * `<micra-each-item style="display:contents">` so the row always corresponds
99
+ * to a single, stable DOM element. Scans, binds listeners once, and caches
100
+ * an empty itemState prototyped from `state` (filled in by the caller).
101
+ */
102
+ function createRowNode<S extends StateRecord>(
103
+ tmpl: MicraTemplate,
104
+ state: StateRecord,
105
+ instance: InternalInstance<S>,
106
+ ): MicraElement {
107
+ const frag = tmpl.content.cloneNode(true) as DocumentFragment
108
+ let node: MicraElement
109
+ if (frag.childNodes.length === 1) {
110
+ node = frag.firstElementChild as MicraElement
111
+ } else {
112
+ node = document.createElement('micra-each-item') as MicraElement
113
+ node.style.display = 'contents'
114
+ node.append(frag)
115
+ }
116
+ const rowScan = scanComponent(node)
117
+ node.__micraScan = rowScan
118
+ node._itemState = Object.create(state) as StateRecord
119
+ bindDataOn(rowScan.on, instance)
120
+ bindAtEvents(rowScan.atEvents, instance)
121
+ bindModels(rowScan.model, instance)
122
+ return node
123
+ }
124
+
91
125
  // ── Keyed diff ────────────────────────────────────────────────────────────────
92
126
 
93
127
  function renderKeyed<S extends StateRecord>(
@@ -121,28 +155,9 @@ function renderKeyed<S extends StateRecord>(
121
155
  let node = keyMap.get(key) as MicraElement | undefined
122
156
 
123
157
  if (!node) {
124
- // Clone template and wrap multi-root fragments in a display:contents element
125
- const frag = tmpl.content.cloneNode(true) as DocumentFragment
126
- if (frag.childNodes.length === 1) {
127
- node = frag.firstElementChild as MicraElement
128
- } else {
129
- node = document.createElement('micra-each-item') as MicraElement
130
- node.style.display = 'contents'
131
- node.append(frag)
132
- }
158
+ node = createRowNode(tmpl, state, instance)
133
159
  node.__micraKey = key
134
160
  keyMap.set(key, node)
135
- // Bind data-on / @event / data-model listeners once per row node.
136
- // Scan the row, cache the scan on the node for future re-renders.
137
- const rowScan = scanComponent(node)
138
- node.__micraScan = rowScan
139
- bindDataOn(rowScan.on, instance)
140
- bindAtEvents(rowScan.atEvents, instance)
141
- bindModels(rowScan.model, instance)
142
- // itemState is created once per node and reused across renders.
143
- // item / index / $index are mutated in place each render — avoids
144
- // Object.create + assign on every cycle and lets safeWrapCache hit.
145
- node._itemState = Object.create(state) as StateRecord
146
161
  } else if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === index) {
147
162
  // Item reference and index are unchanged, and no other state key changed
148
163
  // this cycle — the DOM already reflects the latest values. Skip re-render.
@@ -240,8 +255,14 @@ function reorderKeyed(nextNodes: MicraElement[], prevList: MicraElement[], marke
240
255
  }
241
256
  }
242
257
 
243
- // ── Non-keyed (full re-render) ─────────────────────────────────────────────────
258
+ // ── Non-keyed (positional reuse) ──────────────────────────────────────────────
244
259
 
260
+ /**
261
+ * Diff a non-keyed list by length: reuse the first min(prev, next) DOM nodes,
262
+ * remove the tail when the list shrinks, clone fresh rows for the growth delta.
263
+ * Multi-root template rows are wrapped in `<micra-each-item style="display:contents">`
264
+ * — same as keyed mode — so the reused list is one DOM node per row.
265
+ */
245
266
  function renderNoKey<S extends StateRecord>(
246
267
  tmpl: MicraTemplate,
247
268
  items: StateRecord[],
@@ -249,27 +270,58 @@ function renderNoKey<S extends StateRecord>(
249
270
  state: StateRecord,
250
271
  rawState: StateRecord,
251
272
  instance: InternalInstance<S>,
273
+ canSkipUnchanged: boolean,
252
274
  ): void {
253
- tmpl.__micraList.forEach(n => n.remove())
254
- tmpl.__micraList = []
275
+ const prevList = tmpl.__micraList
276
+ const prevLen = prevList.length
277
+ const nextLen = items.length
278
+ const reuseLen = nextLen < prevLen ? nextLen : prevLen
279
+ const nextList: MicraElement[] = new Array(nextLen)
255
280
 
256
- const frag = document.createDocumentFragment()
257
- for (const [index, item] of items.entries()) {
258
- const clone = tmpl.content.cloneNode(true) as DocumentFragment
259
- const itemState = Object.assign(
260
- Object.create(state) as StateRecord,
261
- { item, index, $index: index },
262
- )
263
- // Fresh clone each render → fresh scan each render (uncached).
264
- const fragScan = scanFragment(clone)
265
- applyDirectives(fragScan, itemState, rawState, instance)
266
- bindDataOn(fragScan.on, instance)
267
- bindAtEvents(fragScan.atEvents, instance)
268
- bindModels(fragScan.model, instance)
269
-
270
- const nodes = Array.from(clone.childNodes) as MicraElement[]
271
- nodes.forEach(n => { n.__micraEach = true; frag.append(n) })
272
- tmpl.__micraList.push(...nodes)
281
+ // 1. Reuse [0, reuseLen): refresh itemState, re-apply directives in place.
282
+ for (let i = 0; i < reuseLen; i++) {
283
+ const node = prevList[i]!
284
+ const item = items[i]!
285
+ if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === i) {
286
+ nextList[i] = node
287
+ continue
288
+ }
289
+ node.__micraItem = item
290
+ node.__micraIndex = i
291
+ const itemState = node._itemState!
292
+ itemState.item = item
293
+ itemState.index = i
294
+ itemState.$index = i
295
+ applyDirectives(node.__micraScan!, itemState, rawState, instance)
296
+ nextList[i] = node
273
297
  }
274
- marker.after(frag)
298
+
299
+ // 2. Shrink: remove tail nodes [nextLen, prevLen).
300
+ for (let i = nextLen; i < prevLen; i++) {
301
+ prevList[i]!.remove()
302
+ }
303
+
304
+ // 3. Grow: clone and attach fresh rows for [prevLen, nextLen).
305
+ if (nextLen > prevLen) {
306
+ const frag = document.createDocumentFragment()
307
+ for (let i = prevLen; i < nextLen; i++) {
308
+ const node = createRowNode(tmpl, state, instance)
309
+ const item = items[i]!
310
+ const itemState = node._itemState!
311
+ itemState.item = item
312
+ itemState.index = i
313
+ itemState.$index = i
314
+ node.__micraEach = true
315
+ node.__micraItem = item
316
+ node.__micraIndex = i
317
+ applyDirectives(node.__micraScan!, itemState, rawState, instance)
318
+ nextList[i] = node
319
+ frag.append(node)
320
+ }
321
+ // Insert after the last reused node, or the marker if the list was empty.
322
+ const anchor: ChildNode = prevLen > 0 ? nextList[prevLen - 1]! : marker
323
+ anchor.after(frag)
324
+ }
325
+
326
+ tmpl.__micraList = nextList
275
327
  }
package/src/dom/scan.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  * 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
- * its children are processed by each.ts on every render via scanFragment.
13
+ * its children are processed by each.ts on every render fresh rows
14
+ * are wrapped in a per-row element and scanned via scanComponent.
14
15
  *
15
16
  * Hot-path notes:
16
17
  * - We read `el.attributes` once and switch by suffix. No allocations per
@@ -166,24 +167,3 @@ export function scanComponent(root: Element): ScanIndex {
166
167
  return scan;
167
168
  }
168
169
 
169
- /**
170
- * Scan a DocumentFragment (no-key each clone). Not cached — these fragments
171
- * are temporary and re-cloned every render.
172
- */
173
- export function scanFragment(frag: DocumentFragment): ScanIndex {
174
- const scan = emptyScan();
175
-
176
- const walker = document.createTreeWalker(
177
- frag,
178
- NodeFilter.SHOW_ELEMENT,
179
- NESTED_COMPONENT_FILTER,
180
- );
181
-
182
- let node: Element | null = walker.nextNode() as Element | null;
183
- while (node) {
184
- classify(node, scan);
185
- node = walker.nextNode() as Element | null;
186
- }
187
-
188
- return scan;
189
- }
package/src/index.ts CHANGED
@@ -27,6 +27,9 @@ export type {
27
27
  StateRecord,
28
28
  UnsubFn,
29
29
  EventHandler,
30
+ EventPayload,
31
+ EmitArgs,
32
+ MicraEvents,
30
33
  FetchOptions,
31
34
  ComponentMethods,
32
35
  ComponentBuiltins,
package/src/types.ts CHANGED
@@ -22,6 +22,53 @@ export type UnsubFn = () => void
22
22
  /** Event bus handler. Generic `T` types the payload. */
23
23
  export type EventHandler<T = unknown> = (payload: T) => void
24
24
 
25
+ /**
26
+ * Type-safe event bus registry. Empty by default — augment it via
27
+ * declaration merging to type your application's events.
28
+ *
29
+ * @example
30
+ * declare module 'micra.js' {
31
+ * interface MicraEvents {
32
+ * 'cart:updated': { count: number }
33
+ * 'user:login': { id: number; name: string }
34
+ * 'modal:close': void
35
+ * }
36
+ * }
37
+ *
38
+ * Micra.emit('cart:updated', { count: 3 }) // ✓ typed
39
+ * Micra.emit('cart:updated', { count: '3' }) // ✗ type error
40
+ * Micra.on('user:login', user => user.id) // user: { id, name }
41
+ *
42
+ * Events not present in the interface fall back to `unknown` payload —
43
+ * fully backward-compatible with untyped usage.
44
+ */
45
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
46
+ export interface MicraEvents {}
47
+
48
+ /**
49
+ * Resolves the payload type for an event key. For keys registered in
50
+ * `MicraEvents` returns the declared payload; for any other string key
51
+ * returns `unknown` (preserving backward compatibility).
52
+ */
53
+ export type EventPayload<K extends string> = K extends keyof MicraEvents
54
+ ? MicraEvents[K]
55
+ : unknown
56
+
57
+ /**
58
+ * Tuple of arguments passed to `emit` after the event name. When the
59
+ * payload type for a known event includes `undefined` (or the payload is
60
+ * declared as `void`), the argument is optional. For known events with a
61
+ * required payload, the argument is required. Unknown events accept any
62
+ * optional payload (backward compat).
63
+ */
64
+ export type EmitArgs<K extends string> = K extends keyof MicraEvents
65
+ ? [MicraEvents[K]] extends [void]
66
+ ? [payload?: undefined]
67
+ : undefined extends MicraEvents[K]
68
+ ? [payload?: MicraEvents[K]]
69
+ : [payload: MicraEvents[K]]
70
+ : [payload?: unknown]
71
+
25
72
  /** Options for `this.fetch()`. For GET/HEAD extra keys become query params. */
26
73
  export interface FetchOptions {
27
74
  method?: string
@@ -71,10 +118,16 @@ export interface ComponentBuiltins<S extends StateRecord = StateRecord> {
71
118
  prop<T>(name: string, defaultVal: T): T
72
119
  /** Fetch helper: CSRF header, JSON body, query params, typed errors. */
73
120
  fetch(url: string, options?: FetchOptions): Promise<unknown>
74
- /** Publish an event on the global bus. */
75
- emit(event: string, payload?: unknown): void
76
- /** Subscribe to the global bus. Subscription is auto-removed on destroy(). */
77
- on<T = unknown>(event: string, handler: EventHandler<T>): UnsubFn
121
+ /**
122
+ * Publish an event on the global bus.
123
+ * Payload is typed via the `MicraEvents` interface (augmentable).
124
+ */
125
+ emit<K extends string>(event: K, ...args: EmitArgs<K>): void
126
+ /**
127
+ * Subscribe to the global bus. Subscription is auto-removed on destroy().
128
+ * Handler payload is typed via the `MicraEvents` interface (augmentable).
129
+ */
130
+ on<K extends string>(event: K, handler: (payload: EventPayload<K>) => void): UnsubFn
78
131
  }
79
132
 
80
133
  /**