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/CHANGELOG.md CHANGED
@@ -4,6 +4,81 @@ 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.0] — 2026-05-30
8
+
9
+ ### TypeScript — type-safe event bus
10
+
11
+ - **New augmentable `MicraEvents` interface.** Declare your app's events
12
+ once and `Micra.emit` / `Micra.on` / `this.emit` / `this.on` enforce
13
+ payload types and arity at the call site:
14
+
15
+ ```ts
16
+ declare module 'micra.js' {
17
+ interface MicraEvents {
18
+ 'cart:updated': { count: number }
19
+ 'modal:close': void
20
+ }
21
+ }
22
+
23
+ Micra.emit('cart:updated', { count: 3 }) // ✓
24
+ Micra.emit('cart:updated', { count: '3' }) // ✗ type error
25
+ Micra.emit('modal:close') // ✓ void → no args
26
+ ```
27
+
28
+ - Events that are NOT declared in `MicraEvents` keep the previous
29
+ behaviour — payload typed as `unknown`, optional argument. Untyped
30
+ code keeps compiling unchanged.
31
+ - New exported types: `MicraEvents`, `EventPayload<K>`, `EmitArgs<K>`.
32
+ - Bundle stays at **5.4 KB gzip** — types only, no runtime change.
33
+
34
+ ### Breaking — types only
35
+
36
+ - The legacy `on<T>(event, handler)` generic now infers `T` as the
37
+ event *key*, not the handler payload. Code that explicitly passed a
38
+ payload type via the generic (`Micra.on<User>('user:updated', h)`)
39
+ still compiles, but `h`'s parameter falls back to `unknown` unless
40
+ the event is declared in `MicraEvents`. Migration: register the
41
+ event via `declare module 'micra.js'` and drop the explicit generic.
42
+ No runtime impact.
43
+
44
+ ### Performance — non-keyed `data-each` now reuses DOM nodes
45
+
46
+ - **Non-keyed `<template data-each>` no longer re-renders the whole list
47
+ on every update.** The new path keeps the first `min(prev, next)` row
48
+ nodes in place — only the length delta is touched (tail removed when
49
+ the list shrinks, new rows cloned when it grows). Each retained row
50
+ gets a fresh `itemState` and a re-applied directive pass through its
51
+ cached `__micraScan`, so content updates correctly without the
52
+ remove/re-clone overhead.
53
+ - Row identity is now stable across renders for the no-key path: event
54
+ listeners bound via `data-on` / `@event` / `data-model` survive
55
+ re-renders without re-binding, and DOM-level state (focus, scroll,
56
+ CSS transitions) is preserved on retained rows.
57
+ - Items that didn't change (same reference + same index) skip
58
+ `applyDirectives` entirely when only the `data-each` source array is
59
+ the trigger for this render cycle — same `canSkipUnchanged`
60
+ optimisation the keyed path already had.
61
+ - Bundle: **5.5 KB gzip** (raised guard from 5.4 → 5.5 to give the
62
+ shared row-creation helper room; net code is slightly smaller after
63
+ factoring `createRowNode` out of both keyed and non-keyed paths).
64
+
65
+ ### Breaking — non-keyed multi-root rows now wrap in `<micra-each-item>`
66
+
67
+ - Templates whose `data-each` content has more than one top-level node
68
+ now render each row inside a `<micra-each-item style="display:contents">`
69
+ wrapper, mirroring the keyed path's existing behaviour. The wrapper is
70
+ visually inert (CSS `display:contents` opts out of the box model) but
71
+ it does add one node to the parse tree.
72
+ - Impact:
73
+ - **CSS:** child selectors that targeted `parent > .row` will now
74
+ match `parent > micra-each-item` instead. Use descendant selectors
75
+ (`parent .row`) or update the rules.
76
+ - **Invalid HTML contexts:** templates whose rows are `<tr>` / `<td>` /
77
+ `<li>` inside `<tbody>` / `<tr>` / `<ul>` cannot legally have a
78
+ wrapper between the parent and the row. Hoist the wrapper into the
79
+ template (so the row is single-rooted) or use a `data-key`.
80
+ - Single-root templates are unchanged — by far the common case.
81
+
7
82
  ## [2.2.1] — 2026-05-28
8
83
 
9
84
  ### Performance — batched first list render
package/README.md CHANGED
@@ -182,6 +182,7 @@ this.on(event, handler)
182
182
  - Recipes:
183
183
  - [Todo app](./docs/recipes/todo-app.md)
184
184
  - [Server-sent events (SSE)](./docs/recipes/sse.md)
185
+ - [htmx bridge](./docs/recipes/htmx.md)
185
186
 
186
187
  ## Code generation with LLMs
187
188
 
@@ -10,23 +10,25 @@
10
10
  * Component instances subscribe via `instance.on()` which auto-registers
11
11
  * the unsub token in `instance.__micraSubs` for cleanup on destroy().
12
12
  */
13
- import type { EventHandler, UnsubFn } from '../types';
13
+ import type { EmitArgs, EventPayload, UnsubFn } from '../types';
14
14
  /**
15
15
  * Subscribe to a named event. Returns an unsubscribe function.
16
+ * Payload is typed via the `MicraEvents` interface (augmentable).
16
17
  *
17
18
  * @example
18
19
  * const unsub = on('user:login', (user) => console.log(user))
19
20
  * unsub() // stop listening
20
21
  */
21
- export declare function on<T = unknown>(event: string, handler: EventHandler<T>): UnsubFn;
22
+ export declare function on<K extends string>(event: K, handler: (payload: EventPayload<K>) => void): UnsubFn;
22
23
  /**
23
24
  * Unsubscribe a specific handler from an event.
24
25
  */
25
- export declare function off(event: string, handler: EventHandler): void;
26
+ export declare function off<K extends string>(event: K, handler: (payload: EventPayload<K>) => void): void;
26
27
  /**
27
28
  * Publish an event to all subscribers. Errors are caught per-handler.
29
+ * Payload is typed via the `MicraEvents` interface (augmentable).
28
30
  *
29
31
  * @example
30
32
  * emit('user:updated', { id: 1, name: 'Alice' })
31
33
  */
32
- export declare function emit(event: string, payload?: unknown): void;
34
+ export declare function emit<K extends string>(event: K, ...args: EmitArgs<K>): void;
@@ -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
  import type { InternalInstance, StateRecord } from '../types';
18
21
  /**
@@ -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
@@ -27,8 +28,3 @@ import type { ScanIndex } from "../types";
27
28
  * are free.
28
29
  */
29
30
  export declare function scanComponent(root: Element): ScanIndex;
30
- /**
31
- * Scan a DocumentFragment (no-key each clone). Not cached — these fragments
32
- * are temporary and re-cloned every render.
33
- */
34
- export declare function scanFragment(frag: DocumentFragment): ScanIndex;
package/dist/index.d.ts CHANGED
@@ -21,7 +21,7 @@
21
21
  *
22
22
  * @module Micra
23
23
  */
24
- export type { StateRecord, UnsubFn, EventHandler, FetchOptions, ComponentMethods, ComponentBuiltins, ComponentInstance, ComponentDefinition, } from './types';
24
+ export type { StateRecord, UnsubFn, EventHandler, EventPayload, EmitArgs, MicraEvents, FetchOptions, ComponentMethods, ComponentBuiltins, ComponentInstance, ComponentDefinition, } from './types';
25
25
  export { FetchError } from './utils/fetch';
26
26
  export { define, defineComponent, instances, registry, debug } from './core/registry';
27
27
  export { mount } from './core/mount';
package/dist/micra.cjs.js CHANGED
@@ -1,4 +1,4 @@
1
- /* Micra.js v2.2.1 — https://github.com/micra-js/micra — MIT */
1
+ /* Micra.js v2.3.0 — https://github.com/micra-js/micra — MIT */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -227,8 +227,9 @@ function off(event, handler) {
227
227
  set.delete(handler);
228
228
  if (set.size === 0) _bus.delete(event);
229
229
  }
230
- function emit(event, payload) {
230
+ function emit(event, ...args) {
231
231
  var _a;
232
+ const payload = args[0];
232
233
  (_a = _bus.get(event)) == null ? void 0 : _a.forEach((h) => {
233
234
  try {
234
235
  h(payload);
@@ -535,20 +536,6 @@ function scanComponent(root) {
535
536
  }
536
537
  return scan;
537
538
  }
538
- function scanFragment(frag) {
539
- const scan = emptyScan();
540
- const walker = document.createTreeWalker(
541
- frag,
542
- NodeFilter.SHOW_ELEMENT,
543
- NESTED_COMPONENT_FILTER
544
- );
545
- let node = walker.nextNode();
546
- while (node) {
547
- classify(node, scan);
548
- node = walker.nextNode();
549
- }
550
- return scan;
551
- }
552
539
 
553
540
  // src/dom/each.ts
554
541
  function renderList(templates, state, rawState, instance, triggerKey) {
@@ -579,10 +566,28 @@ function renderList(templates, state, rawState, instance, triggerKey) {
579
566
  if (keyAttr) {
580
567
  renderKeyed(tmpl, items, keyAttr, marker, keyMap, state, rawState, instance, canSkipUnchanged);
581
568
  } else {
582
- renderNoKey(tmpl, items, marker, state, rawState, instance);
569
+ renderNoKey(tmpl, items, marker, state, rawState, instance, canSkipUnchanged);
583
570
  }
584
571
  }
585
572
  }
573
+ function createRowNode(tmpl, state, instance) {
574
+ const frag = tmpl.content.cloneNode(true);
575
+ let node;
576
+ if (frag.childNodes.length === 1) {
577
+ node = frag.firstElementChild;
578
+ } else {
579
+ node = document.createElement("micra-each-item");
580
+ node.style.display = "contents";
581
+ node.append(frag);
582
+ }
583
+ const rowScan = scanComponent(node);
584
+ node.__micraScan = rowScan;
585
+ node._itemState = Object.create(state);
586
+ bindDataOn(rowScan.on, instance);
587
+ bindAtEvents(rowScan.atEvents, instance);
588
+ bindModels(rowScan.model, instance);
589
+ return node;
590
+ }
586
591
  function renderKeyed(tmpl, items, keyAttr, marker, keyMap, state, rawState, instance, canSkipUnchanged) {
587
592
  var _a;
588
593
  const nextKeys = /* @__PURE__ */ new Set();
@@ -602,22 +607,9 @@ function renderKeyed(tmpl, items, keyAttr, marker, keyMap, state, rawState, inst
602
607
  nextKeys.add(key);
603
608
  let node = keyMap.get(key);
604
609
  if (!node) {
605
- const frag = tmpl.content.cloneNode(true);
606
- if (frag.childNodes.length === 1) {
607
- node = frag.firstElementChild;
608
- } else {
609
- node = document.createElement("micra-each-item");
610
- node.style.display = "contents";
611
- node.append(frag);
612
- }
610
+ node = createRowNode(tmpl, state, instance);
613
611
  node.__micraKey = key;
614
612
  keyMap.set(key, node);
615
- const rowScan2 = scanComponent(node);
616
- node.__micraScan = rowScan2;
617
- bindDataOn(rowScan2.on, instance);
618
- bindAtEvents(rowScan2.atEvents, instance);
619
- bindModels(rowScan2.model, instance);
620
- node._itemState = Object.create(state);
621
613
  } else if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === index) {
622
614
  nextNodes.push(node);
623
615
  continue;
@@ -695,29 +687,51 @@ function reorderKeyed(nextNodes, prevList, marker) {
695
687
  anchor = node;
696
688
  }
697
689
  }
698
- function renderNoKey(tmpl, items, marker, state, rawState, instance) {
699
- tmpl.__micraList.forEach((n) => n.remove());
700
- tmpl.__micraList = [];
701
- const frag = document.createDocumentFragment();
702
- for (const [index, item] of items.entries()) {
703
- const clone = tmpl.content.cloneNode(true);
704
- const itemState = Object.assign(
705
- Object.create(state),
706
- { item, index, $index: index }
707
- );
708
- const fragScan = scanFragment(clone);
709
- applyDirectives(fragScan, itemState, rawState, instance);
710
- bindDataOn(fragScan.on, instance);
711
- bindAtEvents(fragScan.atEvents, instance);
712
- bindModels(fragScan.model, instance);
713
- const nodes = Array.from(clone.childNodes);
714
- nodes.forEach((n) => {
715
- n.__micraEach = true;
716
- frag.append(n);
717
- });
718
- tmpl.__micraList.push(...nodes);
690
+ function renderNoKey(tmpl, items, marker, state, rawState, instance, canSkipUnchanged) {
691
+ const prevList = tmpl.__micraList;
692
+ const prevLen = prevList.length;
693
+ const nextLen = items.length;
694
+ const reuseLen = nextLen < prevLen ? nextLen : prevLen;
695
+ const nextList = new Array(nextLen);
696
+ for (let i = 0; i < reuseLen; i++) {
697
+ const node = prevList[i];
698
+ const item = items[i];
699
+ if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === i) {
700
+ nextList[i] = node;
701
+ continue;
702
+ }
703
+ node.__micraItem = item;
704
+ node.__micraIndex = i;
705
+ const itemState = node._itemState;
706
+ itemState.item = item;
707
+ itemState.index = i;
708
+ itemState.$index = i;
709
+ applyDirectives(node.__micraScan, itemState, rawState, instance);
710
+ nextList[i] = node;
711
+ }
712
+ for (let i = nextLen; i < prevLen; i++) {
713
+ prevList[i].remove();
714
+ }
715
+ if (nextLen > prevLen) {
716
+ const frag = document.createDocumentFragment();
717
+ for (let i = prevLen; i < nextLen; i++) {
718
+ const node = createRowNode(tmpl, state, instance);
719
+ const item = items[i];
720
+ const itemState = node._itemState;
721
+ itemState.item = item;
722
+ itemState.index = i;
723
+ itemState.$index = i;
724
+ node.__micraEach = true;
725
+ node.__micraItem = item;
726
+ node.__micraIndex = i;
727
+ applyDirectives(node.__micraScan, itemState, rawState, instance);
728
+ nextList[i] = node;
729
+ frag.append(node);
730
+ }
731
+ const anchor = prevLen > 0 ? nextList[prevLen - 1] : marker;
732
+ anchor.after(frag);
719
733
  }
720
- marker.after(frag);
734
+ tmpl.__micraList = nextList;
721
735
  }
722
736
 
723
737
  // src/dom/refs.ts