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/CHANGELOG.md +118 -0
- package/README.md +44 -0
- package/dist/core/bus.d.ts +6 -4
- package/dist/core/reactive.d.ts +4 -0
- package/dist/dom/directives.d.ts +2 -2
- package/dist/dom/each.d.ts +5 -2
- package/dist/dom/scan.d.ts +5 -9
- package/dist/index.d.ts +1 -1
- package/dist/micra.cjs.js +75 -62
- package/dist/micra.cjs.js.map +3 -3
- package/dist/micra.esm.js +75 -62
- package/dist/micra.esm.js.map +3 -3
- package/dist/micra.js +75 -62
- package/dist/micra.js.map +3 -3
- package/dist/micra.min.js +2 -2
- package/dist/types.d.ts +46 -6
- package/llms-full.txt +162 -14
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/src/core/bus.ts +15 -6
- package/src/core/mount.ts +6 -5
- package/src/core/reactive.ts +6 -1
- package/src/dom/directives.ts +1 -3
- package/src/dom/each.ts +97 -47
- package/src/dom/scan.ts +5 -25
- package/src/index.ts +3 -0
- package/src/types.ts +57 -6
- package/src/dom/query.ts +0 -50
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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<
|
|
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
|
|
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
|
|
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:
|
|
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 = <
|
|
100
|
-
event:
|
|
101
|
-
handler:
|
|
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
|
|
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);
|
package/src/core/reactive.ts
CHANGED
|
@@ -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
|
-
|
|
54
|
+
queueMicrotask(flush)
|
|
50
55
|
}
|
|
51
56
|
}
|
package/src/dom/directives.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
for (
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
}
|