micra.js 2.2.0 → 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 +88 -0
- package/README.md +1 -0
- package/dist/core/bus.d.ts +6 -4
- package/dist/core/reactive.d.ts +1 -1
- package/dist/dom/each.d.ts +11 -7
- package/dist/dom/scan.d.ts +2 -6
- package/dist/index.d.ts +1 -1
- package/dist/micra.cjs.js +172 -86
- package/dist/micra.cjs.js.map +3 -3
- package/dist/micra.esm.js +172 -86
- package/dist/micra.esm.js.map +3 -3
- package/dist/micra.js +172 -86
- package/dist/micra.js.map +3 -3
- package/dist/micra.min.js +2 -2
- package/dist/types.d.ts +50 -5
- 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 +17 -7
- package/src/core/reactive.ts +2 -1
- package/src/dom/directives.ts +5 -2
- package/src/dom/each.ts +190 -59
- package/src/dom/refs.ts +1 -0
- package/src/dom/scan.ts +2 -22
- package/src/index.ts +3 -0
- package/src/types.ts +61 -5
- package/src/utils/expr.ts +34 -21
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,22 +27,24 @@ 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.
|
|
31
34
|
* Scoped itemState makes `item`, `index`, `$index` available in row expressions.
|
|
32
35
|
*
|
|
33
|
-
* @param templates
|
|
34
|
-
* @param state
|
|
35
|
-
* @param rawState
|
|
36
|
-
* @param instance
|
|
36
|
+
* @param templates - Pre-scanned list of <template data-each> elements
|
|
37
|
+
* @param state - Expression state (proxy merging rawState + instance)
|
|
38
|
+
* @param rawState - Raw (non-proxy) state — used for model binding
|
|
39
|
+
* @param instance - Component instance (for event binding)
|
|
40
|
+
* @param triggerKey - Which state key triggered this render (null = initial, 'MULTIPLE' = batch)
|
|
37
41
|
*/
|
|
38
42
|
export function renderList<S extends StateRecord>(
|
|
39
43
|
templates: Element[],
|
|
40
44
|
state: StateRecord,
|
|
41
45
|
rawState: StateRecord,
|
|
42
46
|
instance: InternalInstance<S>,
|
|
47
|
+
triggerKey: string | null | 'MULTIPLE',
|
|
43
48
|
): void {
|
|
44
49
|
for (const tmplEl of templates) {
|
|
45
50
|
if (tmplEl.tagName !== 'TEMPLATE') continue
|
|
@@ -60,10 +65,9 @@ export function renderList<S extends StateRecord>(
|
|
|
60
65
|
|
|
61
66
|
const marker = tmpl.__micraMarker
|
|
62
67
|
const keyMap = tmpl.__micraNodes
|
|
63
|
-
const parent = marker.parentNode
|
|
64
68
|
// The template (and its marker) is currently detached — likely a data-if
|
|
65
69
|
// ancestor unmounted this subtree. Nothing to do until it returns.
|
|
66
|
-
if (!
|
|
70
|
+
if (!marker.parentNode) continue
|
|
67
71
|
|
|
68
72
|
// Empty / non-array: clear all rendered rows
|
|
69
73
|
if (!Array.isArray(items)) {
|
|
@@ -73,14 +77,51 @@ export function renderList<S extends StateRecord>(
|
|
|
73
77
|
continue
|
|
74
78
|
}
|
|
75
79
|
|
|
80
|
+
// canSkipUnchanged: true when only this list's state key changed — rows
|
|
81
|
+
// whose item reference and index are both unchanged can skip applyDirectives.
|
|
82
|
+
const canSkipUnchanged = triggerKey !== null &&
|
|
83
|
+
triggerKey !== 'MULTIPLE' &&
|
|
84
|
+
triggerKey === itemsExpr
|
|
85
|
+
|
|
76
86
|
if (keyAttr) {
|
|
77
|
-
renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap,
|
|
87
|
+
renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, state, rawState, instance, canSkipUnchanged)
|
|
78
88
|
} else {
|
|
79
|
-
renderNoKey(tmpl, items as StateRecord[], marker,
|
|
89
|
+
renderNoKey(tmpl, items as StateRecord[], marker, state, rawState, instance, canSkipUnchanged)
|
|
80
90
|
}
|
|
81
91
|
}
|
|
82
92
|
}
|
|
83
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
|
+
|
|
84
125
|
// ── Keyed diff ────────────────────────────────────────────────────────────────
|
|
85
126
|
|
|
86
127
|
function renderKeyed<S extends StateRecord>(
|
|
@@ -89,10 +130,10 @@ function renderKeyed<S extends StateRecord>(
|
|
|
89
130
|
keyAttr: string,
|
|
90
131
|
marker: Comment,
|
|
91
132
|
keyMap: Map<unknown, MicraElement>,
|
|
92
|
-
parent: Node,
|
|
93
133
|
state: StateRecord,
|
|
94
134
|
rawState: StateRecord,
|
|
95
135
|
instance: InternalInstance<S>,
|
|
136
|
+
canSkipUnchanged: boolean,
|
|
96
137
|
): void {
|
|
97
138
|
const nextKeys = new Set<unknown>()
|
|
98
139
|
const nextNodes: MicraElement[] = []
|
|
@@ -114,30 +155,25 @@ function renderKeyed<S extends StateRecord>(
|
|
|
114
155
|
let node = keyMap.get(key) as MicraElement | undefined
|
|
115
156
|
|
|
116
157
|
if (!node) {
|
|
117
|
-
|
|
118
|
-
const frag = tmpl.content.cloneNode(true) as DocumentFragment
|
|
119
|
-
if (frag.childNodes.length === 1) {
|
|
120
|
-
node = frag.firstElementChild as MicraElement
|
|
121
|
-
} else {
|
|
122
|
-
node = document.createElement('micra-each-item') as MicraElement
|
|
123
|
-
node.style.display = 'contents'
|
|
124
|
-
node.append(frag)
|
|
125
|
-
}
|
|
158
|
+
node = createRowNode(tmpl, state, instance)
|
|
126
159
|
node.__micraKey = key
|
|
127
160
|
keyMap.set(key, node)
|
|
128
|
-
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
node
|
|
132
|
-
|
|
133
|
-
bindAtEvents(rowScan.atEvents, instance)
|
|
134
|
-
bindModels(rowScan.model, instance)
|
|
161
|
+
} else if (canSkipUnchanged && node.__micraItem === item && node.__micraIndex === index) {
|
|
162
|
+
// Item reference and index are unchanged, and no other state key changed
|
|
163
|
+
// this cycle — the DOM already reflects the latest values. Skip re-render.
|
|
164
|
+
nextNodes.push(node)
|
|
165
|
+
continue
|
|
135
166
|
}
|
|
136
167
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
168
|
+
node.__micraItem = item
|
|
169
|
+
node.__micraIndex = index
|
|
170
|
+
|
|
171
|
+
// Reuse the cached itemState, just update the per-row values.
|
|
172
|
+
const itemState = node._itemState!
|
|
173
|
+
itemState.item = item
|
|
174
|
+
itemState.index = index
|
|
175
|
+
itemState.$index = index
|
|
176
|
+
|
|
141
177
|
// Use the cached scan if present (created above on first sight of this key);
|
|
142
178
|
// older paths may pass a node we haven't scanned yet.
|
|
143
179
|
const rowScan = node.__micraScan ?? (node.__micraScan = scanComponent(node))
|
|
@@ -150,47 +186,142 @@ function renderKeyed<S extends StateRecord>(
|
|
|
150
186
|
if (!nextKeys.has(key)) { node.remove(); keyMap.delete(key) }
|
|
151
187
|
}
|
|
152
188
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
189
|
+
const prevList = tmpl.__micraList
|
|
190
|
+
if (prevList.length === 0) {
|
|
191
|
+
// First render (or refill after a clear): every node is new and already in
|
|
192
|
+
// order — batch into one fragment so the DOM takes a single insertion
|
|
193
|
+
// instead of N anchor.after() calls. Skips LIS entirely.
|
|
194
|
+
if (nextNodes.length) {
|
|
195
|
+
const frag = document.createDocumentFragment()
|
|
196
|
+
for (const node of nextNodes) frag.append(node)
|
|
197
|
+
marker.after(frag)
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
// Skip DOM reorder when list order is unchanged (pure JS array compare, no DOM reads).
|
|
201
|
+
let orderChanged = nextNodes.length !== prevList.length
|
|
202
|
+
if (!orderChanged) {
|
|
203
|
+
for (let i = 0; i < nextNodes.length; i++) {
|
|
204
|
+
if (nextNodes[i] !== prevList[i]) { orderChanged = true; break }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (orderChanged) reorderKeyed(nextNodes, prevList, marker)
|
|
158
208
|
}
|
|
159
209
|
|
|
160
210
|
tmpl.__micraList = nextNodes
|
|
161
211
|
}
|
|
162
212
|
|
|
163
|
-
// ──
|
|
213
|
+
// ── Keyed list reorder (LIS) ───────────────────────────────────────────────────
|
|
164
214
|
|
|
215
|
+
/**
|
|
216
|
+
* Move DOM nodes to match `nextNodes` order using the minimum number of moves.
|
|
217
|
+
*
|
|
218
|
+
* Computes the Longest Increasing Subsequence of each node's position in prevList —
|
|
219
|
+
* nodes in the LIS keep their place. Only the others are re-inserted via anchor.after().
|
|
220
|
+
*
|
|
221
|
+
* Complexity: O(n log n) for LIS, O(k) DOM operations where k = nodes that moved.
|
|
222
|
+
* For a 2-node swap this means 2 DOM ops instead of n.
|
|
223
|
+
*/
|
|
224
|
+
function reorderKeyed(nextNodes: MicraElement[], prevList: MicraElement[], marker: Comment): void {
|
|
225
|
+
const prevPos = new Map<MicraElement, number>()
|
|
226
|
+
for (let i = 0; i < prevList.length; i++) prevPos.set(prevList[i]!, i)
|
|
227
|
+
|
|
228
|
+
const n = nextNodes.length
|
|
229
|
+
const tails: number[] = [] // patience sort: smallest tail at each LIS length
|
|
230
|
+
const tailIdx: number[] = [] // index into nextNodes for each tail
|
|
231
|
+
const prev: number[] = new Array(n).fill(-1)
|
|
232
|
+
|
|
233
|
+
for (let i = 0; i < n; i++) {
|
|
234
|
+
const p = prevPos.get(nextNodes[i]!)
|
|
235
|
+
if (p === undefined) continue // new node — always moved
|
|
236
|
+
let lo = 0, hi = tails.length
|
|
237
|
+
while (lo < hi) { const m = (lo + hi) >> 1; tails[m]! < p ? lo = m + 1 : hi = m }
|
|
238
|
+
if (lo > 0) prev[i] = tailIdx[lo - 1]!
|
|
239
|
+
tails[lo] = p
|
|
240
|
+
tailIdx[lo] = i
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Reconstruct stable (non-moving) set from LIS parent chain
|
|
244
|
+
const stable = new Set<number>()
|
|
245
|
+
let idx: number = tailIdx[tails.length - 1]!
|
|
246
|
+
while (idx >= 0) { stable.add(idx); idx = prev[idx]! }
|
|
247
|
+
|
|
248
|
+
// Move unstable nodes into position; stable (LIS) nodes serve as anchors
|
|
249
|
+
let anchor: ChildNode = marker
|
|
250
|
+
for (let i = 0; i < n; i++) {
|
|
251
|
+
const node = nextNodes[i]!
|
|
252
|
+
if (stable.has(i)) { anchor = node; continue }
|
|
253
|
+
anchor.after(node)
|
|
254
|
+
anchor = node
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ── Non-keyed (positional reuse) ──────────────────────────────────────────────
|
|
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
|
+
*/
|
|
165
266
|
function renderNoKey<S extends StateRecord>(
|
|
166
267
|
tmpl: MicraTemplate,
|
|
167
268
|
items: StateRecord[],
|
|
168
269
|
marker: Comment,
|
|
169
|
-
parent: Node,
|
|
170
270
|
state: StateRecord,
|
|
171
271
|
rawState: StateRecord,
|
|
172
272
|
instance: InternalInstance<S>,
|
|
273
|
+
canSkipUnchanged: boolean,
|
|
173
274
|
): void {
|
|
174
|
-
tmpl.__micraList
|
|
175
|
-
|
|
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)
|
|
176
280
|
|
|
177
|
-
|
|
178
|
-
for (
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// 2. Shrink: remove tail nodes [nextLen, prevLen).
|
|
300
|
+
for (let i = nextLen; i < prevLen; i++) {
|
|
301
|
+
prevList[i]!.remove()
|
|
194
302
|
}
|
|
195
|
-
|
|
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
|
|
196
327
|
}
|
package/src/dom/refs.ts
CHANGED
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
|
/**
|
|
@@ -149,6 +202,9 @@ export interface MicraElement extends HTMLElement {
|
|
|
149
202
|
__micraKey?: unknown // keyed-diff key
|
|
150
203
|
__micraEach?: true // belongs to a no-key each list
|
|
151
204
|
__micraScan?: ScanIndex // single-pass scan result (cached after 1st render)
|
|
205
|
+
__micraItem?: StateRecord // keyed row: last-rendered item ref (for skip check)
|
|
206
|
+
__micraIndex?: number // keyed row: last-rendered index (for skip check)
|
|
207
|
+
_itemState?: StateRecord // keyed row: reused itemState (avoids Object.create per render)
|
|
152
208
|
}
|
|
153
209
|
|
|
154
210
|
/**
|
|
@@ -166,7 +222,7 @@ export interface TrackedListener {
|
|
|
166
222
|
export interface MicraTemplate extends HTMLTemplateElement {
|
|
167
223
|
__micraMarker?: Comment
|
|
168
224
|
__micraNodes: Map<unknown, MicraElement>
|
|
169
|
-
__micraList:
|
|
225
|
+
__micraList: MicraElement[]
|
|
170
226
|
__micraNoKeyWarned?: true
|
|
171
227
|
}
|
|
172
228
|
|
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)
|