micra.js 2.2.1 → 2.3.1

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.1
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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/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.1/dist/micra.min.js"></script>
515
515
  <script>
516
516
  Micra.define('search-bar', {
517
517
  state: { query: '' },
@@ -533,6 +533,154 @@ 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.1/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
+
589
+ ## Recipe 12 — Rails + Micra (importmap, CSRF, Turbo Drive)
590
+
591
+ Pin Micra in `config/importmap.rb`, boot it once in the layout — that's
592
+ the whole integration. CSRF works automatically (Micra reads
593
+ `<meta name="csrf-token">` that Rails already ships). Turbo Drive needs
594
+ a `turbo:load` mirror so the second navigation doesn't ghost.
595
+
596
+ ```ruby
597
+ # config/importmap.rb
598
+ pin "micra",
599
+ to: "https://cdn.jsdelivr.net/npm/micra.js@2.3.1/dist/micra.esm.js",
600
+ preload: true
601
+ ```
602
+
603
+ ```erb
604
+ <%# app/views/layouts/application.html.erb — inside <head> %>
605
+ <%= csrf_meta_tags %>
606
+ <%= javascript_importmap_tags %>
607
+ <script type="module">
608
+ import * as Micra from "micra"
609
+ window.Micra = Micra
610
+ document.addEventListener("DOMContentLoaded", () => Micra.start())
611
+ document.addEventListener("turbo:load", () => Micra.start())
612
+ </script>
613
+ ```
614
+
615
+ ```erb
616
+ <%# app/views/tasks/index.html.erb %>
617
+ <section data-component="tasks-board"
618
+ data-initial-tasks='<%= @tasks.as_json(only: %i[id title done]).to_json %>'>
619
+ <form @submit.prevent="add">
620
+ <input data-model="draft" placeholder="New task…" />
621
+ <button data-bind="disabled:!draft.trim()">Add</button>
622
+ </form>
623
+ <template data-each="tasks" data-key="id">
624
+ <li data-class="done:item.done">
625
+ <input type="checkbox" data-bind="checked:item.done" @change="toggle" />
626
+ <span data-text="item.title"></span>
627
+ <button @click="remove" data-bind="data-id:item.id">×</button>
628
+ </li>
629
+ </template>
630
+ </section>
631
+ ```
632
+
633
+ ```js
634
+ // app/javascript/application.js
635
+ import * as Micra from "micra"
636
+
637
+ Micra.define("tasks-board", {
638
+ state: { tasks: [], draft: "" },
639
+ onCreate() { this.state.tasks = JSON.parse(this.prop("initialTasks") || "[]") },
640
+ async add() {
641
+ const task = await this.fetch("/tasks", { method: "POST", body: { title: this.state.draft.trim() } })
642
+ this.state.tasks = [...this.state.tasks, task]
643
+ this.state.draft = ""
644
+ },
645
+ async toggle(e) {
646
+ const id = Number(e.currentTarget.closest("li").querySelector("[data-id]").dataset.id)
647
+ const next = !this.state.tasks.find(t => t.id === id).done
648
+ const task = await this.fetch(`/tasks/${id}`, { method: "PATCH", body: { done: next } })
649
+ this.state.tasks = this.state.tasks.map(t => t.id === id ? task : t)
650
+ },
651
+ async remove(e) {
652
+ const id = Number(e.currentTarget.dataset.id)
653
+ await this.fetch(`/tasks/${id}`, { method: "DELETE" })
654
+ this.state.tasks = this.state.tasks.filter(t => t.id !== id)
655
+ },
656
+ })
657
+ ```
658
+
659
+ Rules of thumb:
660
+
661
+ - **CSRF is automatic** — keep `<%= csrf_meta_tags %>` in the layout.
662
+ `this.fetch()` sends `X-CSRF-Token` on every non-GET request.
663
+ - **Add `turbo:load`** alongside `DOMContentLoaded` if you have Turbo
664
+ Drive enabled (default since Rails 7). Without it, only the first
665
+ page load wires up.
666
+ - **JSON in `data-*` attributes does NOT auto-parse.** Non-primitive
667
+ props (`@tasks.as_json.to_json`) are strings client-side — call
668
+ `JSON.parse(this.prop('initialTasks'))` in `onCreate`. Primitives
669
+ (numbers, booleans) auto-cast through `this.prop()`.
670
+ - **Turbo Frames** that swap their own `innerHTML` and contain a
671
+ `[data-component]` will leave the cached scan pointing at gone DOM.
672
+ Same footgun as htmx — keep `data-component` outside the frame, or
673
+ destroy+remount on `turbo:frame-render`.
674
+ - The optional **`micra-rails`** gem adds an ERB helper
675
+ (`micra_component`) and a one-shot installer. Its importmap pin can lag
676
+ the latest Micra.js release between gem versions (override it in your
677
+ own `config/importmap.rb`), and the `micra_state.to_html` example in
678
+ its README doesn't compile — see `docs/recipes/rails.md` §2 for
679
+ workarounds.
680
+
681
+ Full reference with Turbo Streams cleanup, no-flicker hydration pattern,
682
+ and the Stimulus-vs-Micra split is in `docs/recipes/rails.md`.
683
+
536
684
  ---
537
685
 
538
686
  # Anti-pattern reference (what LLMs gravitate to — DO NOT)
@@ -569,9 +717,9 @@ import { ref, computed } from 'vue'
569
717
  import Alpine from 'alpinejs'
570
718
 
571
719
  // ❌ 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>
720
+ <script src="https://unpkg.com/micra.js@2.3.1/dist/micra.min.js"></script>
573
721
  // ✅ 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>
722
+ <script src="https://cdn.jsdelivr.net/npm/micra.js@2.3.1/dist/micra.min.js"></script>
575
723
  ```
576
724
 
577
725
  # 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.1/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.1",
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;
@@ -174,7 +175,7 @@ export function mount<S extends StateRecord, M>(
174
175
  const mRoot = root as MicraElement;
175
176
  const scan =
176
177
  mRoot.__micraScan ?? (mRoot.__micraScan = scanComponent(root));
177
- applyDirectives(scan, exprState, rawState, instance);
178
+ applyDirectives(scan, exprState, rawState);
178
179
  renderList(scan.each, exprState, rawState, instance, triggerKey);
179
180
  bindDataOn(scan.on, instance);
180
181
  bindAtEvents(scan.atEvents, instance);
@@ -36,6 +36,10 @@ export function createReactiveState<S extends StateRecord>(obj: S, schedule: ()
36
36
  * Return a debounce function that defers `render` to the next microtask.
37
37
  * Multiple calls within the same tick collapse to a single render.
38
38
  *
39
+ * Uses `queueMicrotask` so each batch enqueues a single microtask instead of
40
+ * allocating a Promise + reaction job. `flush` is hoisted out of the hot path
41
+ * so it isn't re-created on every schedule() call.
42
+ *
39
43
  * @example
40
44
  * const schedule = createScheduler(render)
41
45
  * schedule() // defers render
@@ -43,9 +47,10 @@ export function createReactiveState<S extends StateRecord>(obj: S, schedule: ()
43
47
  */
44
48
  export function createScheduler(render: () => void): () => void {
45
49
  let pending = false
50
+ const flush = () => { pending = false; render() }
46
51
  return function schedule() {
47
52
  if (pending) return
48
53
  pending = true
49
- Promise.resolve().then(() => { pending = false; render() })
54
+ queueMicrotask(flush)
50
55
  }
51
56
  }
@@ -14,7 +14,6 @@
14
14
 
15
15
  import type {
16
16
  CachedIfBinding,
17
- InternalInstance,
18
17
  ScanIndex,
19
18
  StateRecord,
20
19
  } from '../types'
@@ -147,11 +146,10 @@ function applyModel(
147
146
  * @param state - Expression state (may include item/index for each rows)
148
147
  * @param rawState - Raw (non-proxy) state for model sync
149
148
  */
150
- export function applyDirectives<S extends StateRecord>(
149
+ export function applyDirectives(
151
150
  scan: ScanIndex,
152
151
  state: StateRecord,
153
152
  rawState: StateRecord,
154
- _instance: InternalInstance<S>,
155
153
  ): void {
156
154
  // data-if runs first so subsequent directives don't write into a tree that's
157
155
  // about to be detached this tick.
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,8 @@ 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
- }
133
- node.__micraKey = key
158
+ node = createRowNode(tmpl, state, instance)
134
159
  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
160
  } else if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === index) {
147
161
  // Item reference and index are unchanged, and no other state key changed
148
162
  // this cycle — the DOM already reflects the latest values. Skip re-render.
@@ -162,7 +176,7 @@ function renderKeyed<S extends StateRecord>(
162
176
  // Use the cached scan if present (created above on first sight of this key);
163
177
  // older paths may pass a node we haven't scanned yet.
164
178
  const rowScan = node.__micraScan ?? (node.__micraScan = scanComponent(node))
165
- applyDirectives(rowScan, itemState, rawState, instance)
179
+ applyDirectives(rowScan, itemState, rawState)
166
180
  nextNodes.push(node)
167
181
  }
168
182
 
@@ -240,8 +254,14 @@ function reorderKeyed(nextNodes: MicraElement[], prevList: MicraElement[], marke
240
254
  }
241
255
  }
242
256
 
243
- // ── Non-keyed (full re-render) ─────────────────────────────────────────────────
257
+ // ── Non-keyed (positional reuse) ──────────────────────────────────────────────
244
258
 
259
+ /**
260
+ * Diff a non-keyed list by length: reuse the first min(prev, next) DOM nodes,
261
+ * remove the tail when the list shrinks, clone fresh rows for the growth delta.
262
+ * Multi-root template rows are wrapped in `<micra-each-item style="display:contents">`
263
+ * — same as keyed mode — so the reused list is one DOM node per row.
264
+ */
245
265
  function renderNoKey<S extends StateRecord>(
246
266
  tmpl: MicraTemplate,
247
267
  items: StateRecord[],
@@ -249,27 +269,57 @@ function renderNoKey<S extends StateRecord>(
249
269
  state: StateRecord,
250
270
  rawState: StateRecord,
251
271
  instance: InternalInstance<S>,
272
+ canSkipUnchanged: boolean,
252
273
  ): void {
253
- tmpl.__micraList.forEach(n => n.remove())
254
- tmpl.__micraList = []
274
+ const prevList = tmpl.__micraList
275
+ const prevLen = prevList.length
276
+ const nextLen = items.length
277
+ const reuseLen = nextLen < prevLen ? nextLen : prevLen
278
+ const nextList: MicraElement[] = new Array(nextLen)
255
279
 
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)
280
+ // 1. Reuse [0, reuseLen): refresh itemState, re-apply directives in place.
281
+ for (let i = 0; i < reuseLen; i++) {
282
+ const node = prevList[i]!
283
+ const item = items[i]!
284
+ if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === i) {
285
+ nextList[i] = node
286
+ continue
287
+ }
288
+ node.__micraItem = item
289
+ node.__micraIndex = i
290
+ const itemState = node._itemState!
291
+ itemState.item = item
292
+ itemState.index = i
293
+ itemState.$index = i
294
+ applyDirectives(node.__micraScan!, itemState, rawState)
295
+ nextList[i] = node
273
296
  }
274
- marker.after(frag)
297
+
298
+ // 2. Shrink: remove tail nodes [nextLen, prevLen).
299
+ for (let i = nextLen; i < prevLen; i++) {
300
+ prevList[i]!.remove()
301
+ }
302
+
303
+ // 3. Grow: clone and attach fresh rows for [prevLen, nextLen).
304
+ if (nextLen > prevLen) {
305
+ const frag = document.createDocumentFragment()
306
+ for (let i = prevLen; i < nextLen; i++) {
307
+ const node = createRowNode(tmpl, state, instance)
308
+ const item = items[i]!
309
+ const itemState = node._itemState!
310
+ itemState.item = item
311
+ itemState.index = i
312
+ itemState.$index = i
313
+ node.__micraItem = item
314
+ node.__micraIndex = i
315
+ applyDirectives(node.__micraScan!, itemState, rawState)
316
+ nextList[i] = node
317
+ frag.append(node)
318
+ }
319
+ // Insert after the last reused node, or the marker if the list was empty.
320
+ const anchor: ChildNode = prevLen > 0 ? nextList[prevLen - 1]! : marker
321
+ anchor.after(frag)
322
+ }
323
+
324
+ tmpl.__micraList = nextList
275
325
  }