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 +75 -0
- package/README.md +1 -0
- package/dist/core/bus.d.ts +6 -4
- package/dist/dom/each.d.ts +5 -2
- package/dist/dom/scan.d.ts +2 -6
- package/dist/index.d.ts +1 -1
- package/dist/micra.cjs.js +67 -53
- package/dist/micra.cjs.js.map +3 -3
- package/dist/micra.esm.js +67 -53
- package/dist/micra.esm.js.map +3 -3
- package/dist/micra.js +67 -53
- package/dist/micra.js.map +3 -3
- package/dist/micra.min.js +2 -2
- package/dist/types.d.ts +46 -4
- package/llms-full.txt +67 -14
- package/llms.txt +1 -1
- package/package.json +2 -2
- package/src/core/bus.ts +15 -6
- package/src/core/mount.ts +5 -4
- package/src/dom/each.ts +97 -45
- package/src/dom/scan.ts +2 -22
- package/src/index.ts +3 -0
- package/src/types.ts +57 -4
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.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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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<
|
|
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;
|
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,9 @@ 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
|
-
}
|
|
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 (
|
|
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
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
for (
|
|
258
|
-
const
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
|
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
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
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
/**
|