micra.js 2.2.0 → 2.2.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 +13 -0
- package/dist/core/reactive.d.ts +1 -1
- package/dist/dom/each.d.ts +6 -5
- package/dist/micra.cjs.js +110 -38
- package/dist/micra.cjs.js.map +2 -2
- package/dist/micra.esm.js +110 -38
- package/dist/micra.esm.js.map +2 -2
- package/dist/micra.js +110 -38
- package/dist/micra.js.map +2 -2
- package/dist/micra.min.js +2 -2
- package/dist/types.d.ts +4 -1
- package/llms-full.txt +14 -14
- package/llms.txt +1 -1
- package/package.json +1 -1
- package/src/core/mount.ts +12 -3
- package/src/core/reactive.ts +2 -1
- package/src/dom/directives.ts +5 -2
- package/src/dom/each.ts +99 -20
- package/src/dom/refs.ts +1 -0
- package/src/types.ts +4 -1
- package/src/utils/expr.ts +34 -21
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.
|
|
10
|
+
npm install micra.js@^2.2.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.
|
|
20
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.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.
|
|
149
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.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.
|
|
193
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.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.
|
|
265
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.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.
|
|
306
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.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.
|
|
348
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.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.
|
|
397
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.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.
|
|
417
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.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.
|
|
448
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.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.
|
|
482
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.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.
|
|
514
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
|
|
515
515
|
<script>
|
|
516
516
|
Micra.define('search-bar', {
|
|
517
517
|
state: { query: '' },
|
|
@@ -569,9 +569,9 @@ import { ref, computed } from 'vue'
|
|
|
569
569
|
import Alpine from 'alpinejs'
|
|
570
570
|
|
|
571
571
|
// ❌ unpkg CDN — blocked by Claude artifacts and most AI sandbox CSPs
|
|
572
|
-
<script src="https://unpkg.com/micra.js@2.2.
|
|
572
|
+
<script src="https://unpkg.com/micra.js@2.2.1/dist/micra.min.js"></script>
|
|
573
573
|
// ✅ Use jsDelivr instead — it auto-mirrors npm and is CSP-allowlisted everywhere
|
|
574
|
-
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.
|
|
574
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
|
|
575
575
|
```
|
|
576
576
|
|
|
577
577
|
# 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.
|
|
28
|
+
<script src="https://cdn.jsdelivr.net/npm/micra.js@2.2.1/dist/micra.min.js"></script>
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
This exposes a global `Micra` object.
|
package/package.json
CHANGED
package/src/core/mount.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
EventHandler,
|
|
20
20
|
InternalInstance,
|
|
21
21
|
MicraElement,
|
|
22
|
+
|
|
22
23
|
StateRecord,
|
|
23
24
|
UnsubFn,
|
|
24
25
|
} from "../types";
|
|
@@ -107,8 +108,14 @@ export function mount<S extends StateRecord, M>(
|
|
|
107
108
|
|
|
108
109
|
// ── Render ────────────────────────────────────────────────────────────────
|
|
109
110
|
let isRendering = false;
|
|
111
|
+
// Track which state key triggered the current render cycle.
|
|
112
|
+
// 'MULTIPLE' means more than one key was written before the microtask fired.
|
|
113
|
+
let _triggerKey: string | null | "MULTIPLE" = null;
|
|
110
114
|
const schedule = createScheduler(() => instance.render());
|
|
111
|
-
instance.state = createReactiveState(rawState, schedule)
|
|
115
|
+
instance.state = createReactiveState(rawState, schedule, (key) => {
|
|
116
|
+
if (_triggerKey === null) _triggerKey = key;
|
|
117
|
+
else if (_triggerKey !== key) _triggerKey = "MULTIPLE";
|
|
118
|
+
}) as S;
|
|
112
119
|
|
|
113
120
|
// Expression state: proxy that falls back to instance methods so expressions
|
|
114
121
|
// like `data-text="formatDate(item.date)"` can call component methods.
|
|
@@ -149,6 +156,8 @@ export function mount<S extends StateRecord, M>(
|
|
|
149
156
|
let warnedReentry = false;
|
|
150
157
|
instance.render = function () {
|
|
151
158
|
if (instance.__micraDestroyed) return;
|
|
159
|
+
const triggerKey = _triggerKey;
|
|
160
|
+
_triggerKey = null;
|
|
152
161
|
if (isRendering) {
|
|
153
162
|
if (!warnedReentry) {
|
|
154
163
|
warn(
|
|
@@ -166,7 +175,7 @@ export function mount<S extends StateRecord, M>(
|
|
|
166
175
|
const scan =
|
|
167
176
|
mRoot.__micraScan ?? (mRoot.__micraScan = scanComponent(root));
|
|
168
177
|
applyDirectives(scan, exprState, rawState, instance);
|
|
169
|
-
renderList(scan.each, exprState, rawState, instance);
|
|
178
|
+
renderList(scan.each, exprState, rawState, instance, triggerKey);
|
|
170
179
|
bindDataOn(scan.on, instance);
|
|
171
180
|
bindAtEvents(scan.atEvents, instance);
|
|
172
181
|
bindModels(scan.model, instance);
|
|
@@ -187,7 +196,7 @@ export function mount<S extends StateRecord, M>(
|
|
|
187
196
|
);
|
|
188
197
|
instance.__micraListeners = [];
|
|
189
198
|
|
|
190
|
-
// Clear per-element flags & cached
|
|
199
|
+
// Clear per-element flags & cached scan so a future re-mount of the same DOM works.
|
|
191
200
|
const clearFlags = (el: Element) => {
|
|
192
201
|
const m = el as MicraElement;
|
|
193
202
|
delete m.__micraEvents;
|
package/src/core/reactive.ts
CHANGED
|
@@ -20,11 +20,12 @@ import type { StateRecord } from '../types'
|
|
|
20
20
|
* const state = createReactiveState(raw, render)
|
|
21
21
|
* state.count = 5 // triggers render() in next microtask
|
|
22
22
|
*/
|
|
23
|
-
export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void): S {
|
|
23
|
+
export function createReactiveState<S extends StateRecord>(obj: S, schedule: () => void, onKey?: (key: string) => void): S {
|
|
24
24
|
return new Proxy(obj, {
|
|
25
25
|
set(target, key: string, value: unknown) {
|
|
26
26
|
// Cast through StateRecord — TypeScript cannot write through a generic index
|
|
27
27
|
;(target as StateRecord)[key] = value
|
|
28
|
+
onKey?.(key)
|
|
28
29
|
schedule()
|
|
29
30
|
return true
|
|
30
31
|
},
|
package/src/dom/directives.ts
CHANGED
|
@@ -36,7 +36,8 @@ function applyText(el: Element, expr: string, state: StateRecord): void {
|
|
|
36
36
|
* for the full security model.
|
|
37
37
|
*/
|
|
38
38
|
function applyHtml(el: Element, expr: string, state: StateRecord): void {
|
|
39
|
-
|
|
39
|
+
const html = String(evalExpr(expr, state) ?? '')
|
|
40
|
+
if (el.innerHTML !== html) el.innerHTML = html
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
@@ -72,7 +73,9 @@ function applyIf(binding: CachedIfBinding, state: StateRecord): void {
|
|
|
72
73
|
* data-show — visibility toggle via `style.display`. Element stays in the DOM.
|
|
73
74
|
*/
|
|
74
75
|
function applyShow(el: Element, expr: string, state: StateRecord): void {
|
|
75
|
-
|
|
76
|
+
const desired = evalExpr(expr, state) ? '' : 'none'
|
|
77
|
+
const htmlEl = el as HTMLElement
|
|
78
|
+
if (htmlEl.style.display !== desired) htmlEl.style.display = desired
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
function applyBind(
|
package/src/dom/each.ts
CHANGED
|
@@ -30,16 +30,18 @@ import { scanComponent, scanFragment } from './scan'
|
|
|
30
30
|
* Process all `<template data-each>` elements found by the scanner.
|
|
31
31
|
* Scoped itemState makes `item`, `index`, `$index` available in row expressions.
|
|
32
32
|
*
|
|
33
|
-
* @param templates
|
|
34
|
-
* @param state
|
|
35
|
-
* @param rawState
|
|
36
|
-
* @param instance
|
|
33
|
+
* @param templates - Pre-scanned list of <template data-each> elements
|
|
34
|
+
* @param state - Expression state (proxy merging rawState + instance)
|
|
35
|
+
* @param rawState - Raw (non-proxy) state — used for model binding
|
|
36
|
+
* @param instance - Component instance (for event binding)
|
|
37
|
+
* @param triggerKey - Which state key triggered this render (null = initial, 'MULTIPLE' = batch)
|
|
37
38
|
*/
|
|
38
39
|
export function renderList<S extends StateRecord>(
|
|
39
40
|
templates: Element[],
|
|
40
41
|
state: StateRecord,
|
|
41
42
|
rawState: StateRecord,
|
|
42
43
|
instance: InternalInstance<S>,
|
|
44
|
+
triggerKey: string | null | 'MULTIPLE',
|
|
43
45
|
): void {
|
|
44
46
|
for (const tmplEl of templates) {
|
|
45
47
|
if (tmplEl.tagName !== 'TEMPLATE') continue
|
|
@@ -60,10 +62,9 @@ export function renderList<S extends StateRecord>(
|
|
|
60
62
|
|
|
61
63
|
const marker = tmpl.__micraMarker
|
|
62
64
|
const keyMap = tmpl.__micraNodes
|
|
63
|
-
const parent = marker.parentNode
|
|
64
65
|
// The template (and its marker) is currently detached — likely a data-if
|
|
65
66
|
// ancestor unmounted this subtree. Nothing to do until it returns.
|
|
66
|
-
if (!
|
|
67
|
+
if (!marker.parentNode) continue
|
|
67
68
|
|
|
68
69
|
// Empty / non-array: clear all rendered rows
|
|
69
70
|
if (!Array.isArray(items)) {
|
|
@@ -73,10 +74,16 @@ export function renderList<S extends StateRecord>(
|
|
|
73
74
|
continue
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
// canSkipUnchanged: true when only this list's state key changed — rows
|
|
78
|
+
// whose item reference and index are both unchanged can skip applyDirectives.
|
|
79
|
+
const canSkipUnchanged = triggerKey !== null &&
|
|
80
|
+
triggerKey !== 'MULTIPLE' &&
|
|
81
|
+
triggerKey === itemsExpr
|
|
82
|
+
|
|
76
83
|
if (keyAttr) {
|
|
77
|
-
renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap,
|
|
84
|
+
renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, state, rawState, instance, canSkipUnchanged)
|
|
78
85
|
} else {
|
|
79
|
-
renderNoKey(tmpl, items as StateRecord[], marker,
|
|
86
|
+
renderNoKey(tmpl, items as StateRecord[], marker, state, rawState, instance)
|
|
80
87
|
}
|
|
81
88
|
}
|
|
82
89
|
}
|
|
@@ -89,10 +96,10 @@ function renderKeyed<S extends StateRecord>(
|
|
|
89
96
|
keyAttr: string,
|
|
90
97
|
marker: Comment,
|
|
91
98
|
keyMap: Map<unknown, MicraElement>,
|
|
92
|
-
parent: Node,
|
|
93
99
|
state: StateRecord,
|
|
94
100
|
rawState: StateRecord,
|
|
95
101
|
instance: InternalInstance<S>,
|
|
102
|
+
canSkipUnchanged: boolean,
|
|
96
103
|
): void {
|
|
97
104
|
const nextKeys = new Set<unknown>()
|
|
98
105
|
const nextNodes: MicraElement[] = []
|
|
@@ -132,12 +139,26 @@ function renderKeyed<S extends StateRecord>(
|
|
|
132
139
|
bindDataOn(rowScan.on, instance)
|
|
133
140
|
bindAtEvents(rowScan.atEvents, instance)
|
|
134
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
|
+
} else if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === index) {
|
|
147
|
+
// Item reference and index are unchanged, and no other state key changed
|
|
148
|
+
// this cycle — the DOM already reflects the latest values. Skip re-render.
|
|
149
|
+
nextNodes.push(node)
|
|
150
|
+
continue
|
|
135
151
|
}
|
|
136
152
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
153
|
+
node.__micraItem = item
|
|
154
|
+
node.__micraIndex = index
|
|
155
|
+
|
|
156
|
+
// Reuse the cached itemState, just update the per-row values.
|
|
157
|
+
const itemState = node._itemState!
|
|
158
|
+
itemState.item = item
|
|
159
|
+
itemState.index = index
|
|
160
|
+
itemState.$index = index
|
|
161
|
+
|
|
141
162
|
// Use the cached scan if present (created above on first sight of this key);
|
|
142
163
|
// older paths may pass a node we haven't scanned yet.
|
|
143
164
|
const rowScan = node.__micraScan ?? (node.__micraScan = scanComponent(node))
|
|
@@ -150,23 +171,81 @@ function renderKeyed<S extends StateRecord>(
|
|
|
150
171
|
if (!nextKeys.has(key)) { node.remove(); keyMap.delete(key) }
|
|
151
172
|
}
|
|
152
173
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
174
|
+
const prevList = tmpl.__micraList
|
|
175
|
+
if (prevList.length === 0) {
|
|
176
|
+
// First render (or refill after a clear): every node is new and already in
|
|
177
|
+
// order — batch into one fragment so the DOM takes a single insertion
|
|
178
|
+
// instead of N anchor.after() calls. Skips LIS entirely.
|
|
179
|
+
if (nextNodes.length) {
|
|
180
|
+
const frag = document.createDocumentFragment()
|
|
181
|
+
for (const node of nextNodes) frag.append(node)
|
|
182
|
+
marker.after(frag)
|
|
183
|
+
}
|
|
184
|
+
} else {
|
|
185
|
+
// Skip DOM reorder when list order is unchanged (pure JS array compare, no DOM reads).
|
|
186
|
+
let orderChanged = nextNodes.length !== prevList.length
|
|
187
|
+
if (!orderChanged) {
|
|
188
|
+
for (let i = 0; i < nextNodes.length; i++) {
|
|
189
|
+
if (nextNodes[i] !== prevList[i]) { orderChanged = true; break }
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
if (orderChanged) reorderKeyed(nextNodes, prevList, marker)
|
|
158
193
|
}
|
|
159
194
|
|
|
160
195
|
tmpl.__micraList = nextNodes
|
|
161
196
|
}
|
|
162
197
|
|
|
198
|
+
// ── Keyed list reorder (LIS) ───────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Move DOM nodes to match `nextNodes` order using the minimum number of moves.
|
|
202
|
+
*
|
|
203
|
+
* Computes the Longest Increasing Subsequence of each node's position in prevList —
|
|
204
|
+
* nodes in the LIS keep their place. Only the others are re-inserted via anchor.after().
|
|
205
|
+
*
|
|
206
|
+
* Complexity: O(n log n) for LIS, O(k) DOM operations where k = nodes that moved.
|
|
207
|
+
* For a 2-node swap this means 2 DOM ops instead of n.
|
|
208
|
+
*/
|
|
209
|
+
function reorderKeyed(nextNodes: MicraElement[], prevList: MicraElement[], marker: Comment): void {
|
|
210
|
+
const prevPos = new Map<MicraElement, number>()
|
|
211
|
+
for (let i = 0; i < prevList.length; i++) prevPos.set(prevList[i]!, i)
|
|
212
|
+
|
|
213
|
+
const n = nextNodes.length
|
|
214
|
+
const tails: number[] = [] // patience sort: smallest tail at each LIS length
|
|
215
|
+
const tailIdx: number[] = [] // index into nextNodes for each tail
|
|
216
|
+
const prev: number[] = new Array(n).fill(-1)
|
|
217
|
+
|
|
218
|
+
for (let i = 0; i < n; i++) {
|
|
219
|
+
const p = prevPos.get(nextNodes[i]!)
|
|
220
|
+
if (p === undefined) continue // new node — always moved
|
|
221
|
+
let lo = 0, hi = tails.length
|
|
222
|
+
while (lo < hi) { const m = (lo + hi) >> 1; tails[m]! < p ? lo = m + 1 : hi = m }
|
|
223
|
+
if (lo > 0) prev[i] = tailIdx[lo - 1]!
|
|
224
|
+
tails[lo] = p
|
|
225
|
+
tailIdx[lo] = i
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Reconstruct stable (non-moving) set from LIS parent chain
|
|
229
|
+
const stable = new Set<number>()
|
|
230
|
+
let idx: number = tailIdx[tails.length - 1]!
|
|
231
|
+
while (idx >= 0) { stable.add(idx); idx = prev[idx]! }
|
|
232
|
+
|
|
233
|
+
// Move unstable nodes into position; stable (LIS) nodes serve as anchors
|
|
234
|
+
let anchor: ChildNode = marker
|
|
235
|
+
for (let i = 0; i < n; i++) {
|
|
236
|
+
const node = nextNodes[i]!
|
|
237
|
+
if (stable.has(i)) { anchor = node; continue }
|
|
238
|
+
anchor.after(node)
|
|
239
|
+
anchor = node
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
163
243
|
// ── Non-keyed (full re-render) ─────────────────────────────────────────────────
|
|
164
244
|
|
|
165
245
|
function renderNoKey<S extends StateRecord>(
|
|
166
246
|
tmpl: MicraTemplate,
|
|
167
247
|
items: StateRecord[],
|
|
168
248
|
marker: Comment,
|
|
169
|
-
parent: Node,
|
|
170
249
|
state: StateRecord,
|
|
171
250
|
rawState: StateRecord,
|
|
172
251
|
instance: InternalInstance<S>,
|
|
@@ -192,5 +271,5 @@ function renderNoKey<S extends StateRecord>(
|
|
|
192
271
|
nodes.forEach(n => { n.__micraEach = true; frag.append(n) })
|
|
193
272
|
tmpl.__micraList.push(...nodes)
|
|
194
273
|
}
|
|
195
|
-
|
|
274
|
+
marker.after(frag)
|
|
196
275
|
}
|
package/src/dom/refs.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -149,6 +149,9 @@ export interface MicraElement extends HTMLElement {
|
|
|
149
149
|
__micraKey?: unknown // keyed-diff key
|
|
150
150
|
__micraEach?: true // belongs to a no-key each list
|
|
151
151
|
__micraScan?: ScanIndex // single-pass scan result (cached after 1st render)
|
|
152
|
+
__micraItem?: StateRecord // keyed row: last-rendered item ref (for skip check)
|
|
153
|
+
__micraIndex?: number // keyed row: last-rendered index (for skip check)
|
|
154
|
+
_itemState?: StateRecord // keyed row: reused itemState (avoids Object.create per render)
|
|
152
155
|
}
|
|
153
156
|
|
|
154
157
|
/**
|
|
@@ -166,7 +169,7 @@ export interface TrackedListener {
|
|
|
166
169
|
export interface MicraTemplate extends HTMLTemplateElement {
|
|
167
170
|
__micraMarker?: Comment
|
|
168
171
|
__micraNodes: Map<unknown, MicraElement>
|
|
169
|
-
__micraList:
|
|
172
|
+
__micraList: MicraElement[]
|
|
170
173
|
__micraNoKeyWarned?: true
|
|
171
174
|
}
|
|
172
175
|
|
package/src/utils/expr.ts
CHANGED
|
@@ -29,8 +29,15 @@ import type { StateRecord } from '../types'
|
|
|
29
29
|
|
|
30
30
|
// LLM NOTE: exprCache is module-level (shared across all components).
|
|
31
31
|
// This is intentional — most apps reuse the same expressions.
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
|
|
33
|
+
// Compiled fn for complex expressions; pre-split parts for simple dot-paths.
|
|
34
|
+
// Storing parts once avoids the SIMPLE_PATH regex test + split on every evalExpr call.
|
|
35
|
+
type CompiledFn = (state: object, safe: object) => unknown
|
|
36
|
+
type CachedEntry =
|
|
37
|
+
| { kind: 'fn'; fn: CompiledFn }
|
|
38
|
+
| { kind: 'path'; parts: string[] }
|
|
39
|
+
|
|
40
|
+
const exprCache = new Map<string, CachedEntry>()
|
|
34
41
|
// Expressions whose runtime error we have already warned about. Prevents log spam
|
|
35
42
|
// when the same `data-text="item.naame"` typo fires every render.
|
|
36
43
|
const warnedRuntime = new Set<string>()
|
|
@@ -141,32 +148,38 @@ function safeStateHas(state: object, key: PropertyKey): boolean {
|
|
|
141
148
|
* evalExpr('price * qty', { price: 9.99, qty: 3 }) // → 29.97
|
|
142
149
|
*/
|
|
143
150
|
export function evalExpr(expr: string, state: StateRecord): unknown {
|
|
151
|
+
let cached = exprCache.get(expr)
|
|
152
|
+
|
|
153
|
+
if (!cached) {
|
|
154
|
+
// Determine once whether this is a simple dot-path and cache the result.
|
|
155
|
+
if (SIMPLE_PATH.test(expr)) {
|
|
156
|
+
cached = { kind: 'path', parts: expr.split('.') }
|
|
157
|
+
} else {
|
|
158
|
+
try {
|
|
159
|
+
cached = {
|
|
160
|
+
kind: 'fn',
|
|
161
|
+
fn: new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as CompiledFn,
|
|
162
|
+
}
|
|
163
|
+
} catch {
|
|
164
|
+
warn(`invalid expression "${expr}"`)
|
|
165
|
+
cached = { kind: 'fn', fn: () => undefined }
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
exprCache.set(expr, cached)
|
|
169
|
+
}
|
|
170
|
+
|
|
144
171
|
// Fast-path: simple property access — no Function() needed.
|
|
145
172
|
// Still guarded so bare access to Object.prototype names returns undefined.
|
|
146
|
-
if (
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
obj != null ? (obj as StateRecord)[key] : undefined,
|
|
173
|
+
if (cached.kind === 'path') {
|
|
174
|
+
if (!safeStateHas(state, cached.parts[0]!)) return undefined
|
|
175
|
+
return cached.parts.reduce<unknown>(
|
|
176
|
+
(obj, key) => (obj != null ? (obj as StateRecord)[key] : undefined),
|
|
151
177
|
state,
|
|
152
178
|
)
|
|
153
179
|
}
|
|
154
180
|
|
|
155
|
-
if (!exprCache.has(expr)) {
|
|
156
|
-
try {
|
|
157
|
-
// Two with() statements: $s wins for state keys; $safe shadows globals.
|
|
158
|
-
exprCache.set(
|
|
159
|
-
expr,
|
|
160
|
-
new Function('$s', '$safe', `with($safe){with($s){return (${expr})}}`) as Compiled,
|
|
161
|
-
)
|
|
162
|
-
} catch {
|
|
163
|
-
warn(`invalid expression "${expr}"`)
|
|
164
|
-
exprCache.set(expr, () => undefined)
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
181
|
try {
|
|
169
|
-
return
|
|
182
|
+
return cached.fn(safeStateWrap(state), SAFE_OUTER)
|
|
170
183
|
} catch (e) {
|
|
171
184
|
if (!warnedRuntime.has(expr)) {
|
|
172
185
|
warnedRuntime.add(expr)
|