micra.js 2.1.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 +244 -0
- package/README.md +39 -14
- package/dist/core/mount.d.ts +8 -2
- package/dist/core/reactive.d.ts +1 -1
- package/dist/core/registry.d.ts +13 -4
- package/dist/dom/directives.d.ts +11 -15
- package/dist/dom/each.d.ts +10 -7
- package/dist/dom/events.d.ts +15 -5
- package/dist/dom/refs.d.ts +5 -5
- package/dist/dom/scan.d.ts +34 -0
- package/dist/index.d.ts +1 -1
- package/dist/micra.cjs.js +302 -177
- package/dist/micra.cjs.js.map +4 -4
- package/dist/micra.esm.js +302 -177
- package/dist/micra.esm.js.map +4 -4
- package/dist/micra.js +302 -177
- package/dist/micra.js.map +4 -4
- package/dist/micra.min.js +2 -2
- package/dist/types.d.ts +67 -22
- package/llms-full.txt +600 -0
- package/llms.txt +148 -0
- package/package.json +11 -3
- package/src/core/mount.ts +136 -99
- package/src/core/reactive.ts +2 -1
- package/src/core/registry.ts +19 -9
- package/src/dom/directives.ts +39 -122
- package/src/dom/each.ts +133 -37
- package/src/dom/events.ts +23 -31
- package/src/dom/refs.ts +7 -7
- package/src/dom/scan.ts +189 -0
- package/src/index.ts +2 -0
- package/src/types.ts +80 -22
- package/src/utils/expr.ts +34 -21
package/src/dom/directives.ts
CHANGED
|
@@ -4,27 +4,21 @@
|
|
|
4
4
|
* Responsibilities:
|
|
5
5
|
* - data-text, data-html, data-if, data-show, data-bind, data-model
|
|
6
6
|
* - data-class (additive class toggling)
|
|
7
|
-
* - Directive result cache (built once per element, reused on re-renders)
|
|
8
7
|
*
|
|
9
|
-
* LLM NOTE: applyDirectives() is called on every render.
|
|
10
|
-
* (
|
|
11
|
-
*
|
|
8
|
+
* LLM NOTE: applyDirectives() is called on every render. It consumes a
|
|
9
|
+
* pre-computed ScanIndex (built once by scan.ts and cached on the element).
|
|
10
|
+
* The scan replaced 10+ querySelectorAll calls with a single TreeWalker pass.
|
|
12
11
|
*
|
|
13
12
|
* Important: this module does NOT handle data-each — see dom/each.ts.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
15
|
import type {
|
|
17
|
-
CachedBinding,
|
|
18
16
|
CachedIfBinding,
|
|
19
|
-
CachedPairBinding,
|
|
20
|
-
DirectiveCache,
|
|
21
17
|
InternalInstance,
|
|
22
|
-
|
|
23
|
-
MicraTemplate,
|
|
18
|
+
ScanIndex,
|
|
24
19
|
StateRecord,
|
|
25
20
|
} from '../types'
|
|
26
21
|
import { evalExpr, warn } from '../utils/expr'
|
|
27
|
-
import { queryOwn, queryAll } from './query'
|
|
28
22
|
|
|
29
23
|
// ── Directive appliers ────────────────────────────────────────────────────────
|
|
30
24
|
// Each function is PURE relative to state — reads state, writes DOM.
|
|
@@ -42,7 +36,8 @@ function applyText(el: Element, expr: string, state: StateRecord): void {
|
|
|
42
36
|
* for the full security model.
|
|
43
37
|
*/
|
|
44
38
|
function applyHtml(el: Element, expr: string, state: StateRecord): void {
|
|
45
|
-
|
|
39
|
+
const html = String(evalExpr(expr, state) ?? '')
|
|
40
|
+
if (el.innerHTML !== html) el.innerHTML = html
|
|
46
41
|
}
|
|
47
42
|
|
|
48
43
|
/**
|
|
@@ -78,7 +73,9 @@ function applyIf(binding: CachedIfBinding, state: StateRecord): void {
|
|
|
78
73
|
* data-show — visibility toggle via `style.display`. Element stays in the DOM.
|
|
79
74
|
*/
|
|
80
75
|
function applyShow(el: Element, expr: string, state: StateRecord): void {
|
|
81
|
-
|
|
76
|
+
const desired = evalExpr(expr, state) ? '' : 'none'
|
|
77
|
+
const htmlEl = el as HTMLElement
|
|
78
|
+
if (htmlEl.style.display !== desired) htmlEl.style.display = desired
|
|
82
79
|
}
|
|
83
80
|
|
|
84
81
|
function applyBind(
|
|
@@ -110,13 +107,8 @@ function applyBind(
|
|
|
110
107
|
|
|
111
108
|
/**
|
|
112
109
|
* data-class="active:isActive, disabled:count === 0"
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* Syntax mirrors data-bind — split by comma, then by first colon.
|
|
117
|
-
*
|
|
118
|
-
* @example
|
|
119
|
-
* <div data-class="active:tab === 'home', hidden:!loaded">
|
|
110
|
+
* Toggles classes additively (does NOT replace full className like data-bind:class).
|
|
111
|
+
* Pairs are pre-parsed at scan time.
|
|
120
112
|
*/
|
|
121
113
|
function applyClass(
|
|
122
114
|
el: Element,
|
|
@@ -128,20 +120,6 @@ function applyClass(
|
|
|
128
120
|
}
|
|
129
121
|
}
|
|
130
122
|
|
|
131
|
-
/** @internal Parse a comma+colon spec like `href:url, disabled:loading` once. */
|
|
132
|
-
function parsePairs(expr: string): Array<readonly [string, string]> {
|
|
133
|
-
const out: Array<readonly [string, string]> = []
|
|
134
|
-
for (const part of expr.split(',')) {
|
|
135
|
-
const colonIdx = part.indexOf(':')
|
|
136
|
-
if (colonIdx === -1) continue
|
|
137
|
-
const left = part.slice(0, colonIdx).trim()
|
|
138
|
-
const right = part.slice(colonIdx + 1).trim()
|
|
139
|
-
if (!left) continue
|
|
140
|
-
out.push([left, right])
|
|
141
|
-
}
|
|
142
|
-
return out
|
|
143
|
-
}
|
|
144
|
-
|
|
145
123
|
function applyModel(
|
|
146
124
|
el: Element,
|
|
147
125
|
key: string,
|
|
@@ -157,126 +135,65 @@ function applyModel(
|
|
|
157
135
|
// listener is attached separately in events.ts — this only syncs the value
|
|
158
136
|
}
|
|
159
137
|
|
|
160
|
-
// ── Directive cache ───────────────────────────────────────────────────────────
|
|
161
|
-
|
|
162
|
-
/** @internal Collect all directive bindings for a root element. Built once. */
|
|
163
|
-
function buildCache(root: Element): DirectiveCache {
|
|
164
|
-
const pick = (attr: string): CachedBinding[] => {
|
|
165
|
-
const els = queryOwn(root, attr)
|
|
166
|
-
// Include root itself
|
|
167
|
-
if ((root as HTMLElement).hasAttribute?.(attr)) els.unshift(root)
|
|
168
|
-
return els
|
|
169
|
-
.filter(el => !el.closest('template'))
|
|
170
|
-
.map(el => ({ el, expr: el.getAttribute(attr)! }))
|
|
171
|
-
}
|
|
172
|
-
const pickPairs = (attr: string): CachedPairBinding[] =>
|
|
173
|
-
pick(attr).map(b => ({ ...b, pairs: parsePairs(b.expr) }))
|
|
174
|
-
return {
|
|
175
|
-
text: pick('data-text'),
|
|
176
|
-
html: pick('data-html'),
|
|
177
|
-
if: pick('data-if') as CachedIfBinding[],
|
|
178
|
-
show: pick('data-show'),
|
|
179
|
-
bind: pickPairs('data-bind'),
|
|
180
|
-
model: pick('data-model'),
|
|
181
|
-
class: pickPairs('data-class'),
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
138
|
// ── Main entry point ──────────────────────────────────────────────────────────
|
|
186
139
|
|
|
187
140
|
/**
|
|
188
141
|
* Apply all non-each directives to a component subtree.
|
|
189
142
|
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
* For DocumentFragments (no-key each clones): always re-scan because these
|
|
194
|
-
* fragments are new clones on every render.
|
|
143
|
+
* Consumes a pre-computed ScanIndex. data-if runs first so subsequent
|
|
144
|
+
* directives don't write into a tree that's about to be detached this tick.
|
|
195
145
|
*
|
|
196
|
-
* @param
|
|
146
|
+
* @param scan - Pre-computed scan from scan.ts (cached per element)
|
|
197
147
|
* @param state - Expression state (may include item/index for each rows)
|
|
198
148
|
* @param rawState - Raw (non-proxy) state for model sync
|
|
199
|
-
* @param instance - Component instance (unused here, kept for future hooks)
|
|
200
149
|
*/
|
|
201
150
|
export function applyDirectives<S extends StateRecord>(
|
|
202
|
-
|
|
151
|
+
scan: ScanIndex,
|
|
203
152
|
state: StateRecord,
|
|
204
153
|
rawState: StateRecord,
|
|
205
154
|
_instance: InternalInstance<S>,
|
|
206
|
-
): void {
|
|
207
|
-
// DocumentFragments are temporary clones — always scan, never cache
|
|
208
|
-
if (root.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
|
|
209
|
-
applyFromList(buildFragmentList(root as DocumentFragment), state, rawState)
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
const el = root as MicraElement
|
|
214
|
-
if (!el.__micraCache) el.__micraCache = buildCache(el)
|
|
215
|
-
applyFromList(el.__micraCache, state, rawState)
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/** @internal Apply a pre-built cache / binding list to current state. */
|
|
219
|
-
function applyFromList(
|
|
220
|
-
cache: DirectiveCache,
|
|
221
|
-
state: StateRecord,
|
|
222
|
-
rawState: StateRecord,
|
|
223
155
|
): void {
|
|
224
156
|
// data-if runs first so subsequent directives don't write into a tree that's
|
|
225
157
|
// about to be detached this tick.
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/** @internal Scan a DocumentFragment (no-key each clone) — returns a DirectiveCache. */
|
|
236
|
-
function buildFragmentList(frag: DocumentFragment): DirectiveCache {
|
|
237
|
-
const pick = (attr: string): CachedBinding[] =>
|
|
238
|
-
queryAll(frag, `[${attr}]`)
|
|
239
|
-
.filter(el => !el.closest('template'))
|
|
240
|
-
.map(el => ({ el, expr: el.getAttribute(attr)! }))
|
|
241
|
-
const pickPairs = (attr: string): CachedPairBinding[] =>
|
|
242
|
-
pick(attr).map(b => ({ ...b, pairs: parsePairs(b.expr) }))
|
|
243
|
-
return {
|
|
244
|
-
text: pick('data-text'),
|
|
245
|
-
html: pick('data-html'),
|
|
246
|
-
if: pick('data-if') as CachedIfBinding[],
|
|
247
|
-
show: pick('data-show'),
|
|
248
|
-
bind: pickPairs('data-bind'),
|
|
249
|
-
model: pick('data-model'),
|
|
250
|
-
class: pickPairs('data-class'),
|
|
251
|
-
}
|
|
158
|
+
for (const b of scan.if) applyIf(b, state)
|
|
159
|
+
for (const b of scan.text) applyText(b.el, b.expr, state)
|
|
160
|
+
for (const b of scan.html) applyHtml(b.el, b.expr, state)
|
|
161
|
+
for (const b of scan.show) applyShow(b.el, b.expr, state)
|
|
162
|
+
for (const b of scan.bind) applyBind(b.el, b.pairs, state)
|
|
163
|
+
for (const b of scan.model) applyModel(b.el, b.expr.trim(), rawState)
|
|
164
|
+
for (const b of scan.class) applyClass(b.el, b.pairs, state)
|
|
252
165
|
}
|
|
253
166
|
|
|
254
167
|
// ── Dev warning helper ────────────────────────────────────────────────────────
|
|
255
168
|
|
|
256
169
|
/**
|
|
257
170
|
* Validate directive usage and emit dev warnings.
|
|
258
|
-
* Called once after the initial render of a component
|
|
171
|
+
* Called once after the initial render of a component, with the already-built
|
|
172
|
+
* scan so we don't walk the DOM again.
|
|
259
173
|
*
|
|
260
174
|
* @internal
|
|
261
175
|
*/
|
|
262
|
-
export function validateDirectives(
|
|
263
|
-
|
|
264
|
-
const tmpl = el as
|
|
176
|
+
export function validateDirectives(scan: ScanIndex): void {
|
|
177
|
+
for (const el of scan.each) {
|
|
178
|
+
const tmpl = el as HTMLTemplateElement & { __micraNoKeyWarned?: true }
|
|
265
179
|
if (!el.hasAttribute('data-key') && !tmpl.__micraNoKeyWarned) {
|
|
266
180
|
tmpl.__micraNoKeyWarned = true
|
|
267
|
-
warn(
|
|
181
|
+
warn(
|
|
182
|
+
`data-each="${el.getAttribute('data-each')}" has no data-key — ` +
|
|
183
|
+
`keyed diff disabled. Add data-key="id" for better performance.`,
|
|
184
|
+
)
|
|
268
185
|
}
|
|
269
|
-
}
|
|
186
|
+
}
|
|
270
187
|
|
|
271
188
|
// data-bind="class:..." replaces className wholesale, which fights with
|
|
272
189
|
// data-class on the same element. Warn so the developer picks one.
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
190
|
+
for (const b of scan.bind) {
|
|
191
|
+
const hasClassBind = b.pairs.some(p => p[0] === 'class')
|
|
192
|
+
if (hasClassBind && b.el.hasAttribute('data-class')) {
|
|
193
|
+
warn(
|
|
194
|
+
`element has both data-bind="class:..." and data-class — they fight ` +
|
|
195
|
+
`on every render. Use one.`,
|
|
196
|
+
)
|
|
280
197
|
}
|
|
281
198
|
}
|
|
282
199
|
}
|
package/src/dom/each.ts
CHANGED
|
@@ -8,34 +8,43 @@
|
|
|
8
8
|
* - Apply directives to each row with a scoped itemState
|
|
9
9
|
*
|
|
10
10
|
* LLM NOTE: renderList() is called on every render cycle AFTER applyDirectives().
|
|
11
|
-
*
|
|
11
|
+
* The template list comes pre-scanned from scan.ts — no DOM queries here.
|
|
12
|
+
* Each row node gets its own ScanIndex cached on `node.__micraScan` so
|
|
13
|
+
* re-renders of that row don't re-walk the DOM.
|
|
12
14
|
* Keyed mode (data-key present) mutates the DOM in-place — nodes are
|
|
13
15
|
* created once and reused. Non-keyed mode removes all nodes and re-clones.
|
|
14
16
|
*/
|
|
15
17
|
|
|
16
|
-
import type {
|
|
18
|
+
import type {
|
|
19
|
+
InternalInstance,
|
|
20
|
+
MicraElement,
|
|
21
|
+
MicraTemplate,
|
|
22
|
+
StateRecord,
|
|
23
|
+
} from '../types'
|
|
17
24
|
import { evalExpr, warn } from '../utils/expr'
|
|
18
25
|
import { applyDirectives } from './directives'
|
|
19
|
-
import { bindDataOn, bindAtEvents } from './events'
|
|
20
|
-
import {
|
|
26
|
+
import { bindDataOn, bindAtEvents, bindModels } from './events'
|
|
27
|
+
import { scanComponent, scanFragment } from './scan'
|
|
21
28
|
|
|
22
29
|
/**
|
|
23
|
-
* Process all `<template data-each>` elements
|
|
30
|
+
* Process all `<template data-each>` elements found by the scanner.
|
|
24
31
|
* Scoped itemState makes `item`, `index`, `$index` available in row expressions.
|
|
25
32
|
*
|
|
26
|
-
* @param
|
|
27
|
-
* @param state
|
|
28
|
-
* @param rawState
|
|
29
|
-
* @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)
|
|
30
38
|
*/
|
|
31
39
|
export function renderList<S extends StateRecord>(
|
|
32
|
-
|
|
40
|
+
templates: Element[],
|
|
33
41
|
state: StateRecord,
|
|
34
42
|
rawState: StateRecord,
|
|
35
43
|
instance: InternalInstance<S>,
|
|
44
|
+
triggerKey: string | null | 'MULTIPLE',
|
|
36
45
|
): void {
|
|
37
|
-
|
|
38
|
-
if (tmplEl.tagName !== 'TEMPLATE')
|
|
46
|
+
for (const tmplEl of templates) {
|
|
47
|
+
if (tmplEl.tagName !== 'TEMPLATE') continue
|
|
39
48
|
const tmpl = tmplEl as MicraTemplate
|
|
40
49
|
|
|
41
50
|
const itemsExpr = tmpl.getAttribute('data-each')!
|
|
@@ -53,25 +62,30 @@ export function renderList<S extends StateRecord>(
|
|
|
53
62
|
|
|
54
63
|
const marker = tmpl.__micraMarker
|
|
55
64
|
const keyMap = tmpl.__micraNodes
|
|
56
|
-
const parent = marker.parentNode
|
|
57
65
|
// The template (and its marker) is currently detached — likely a data-if
|
|
58
66
|
// ancestor unmounted this subtree. Nothing to do until it returns.
|
|
59
|
-
if (!
|
|
67
|
+
if (!marker.parentNode) continue
|
|
60
68
|
|
|
61
69
|
// Empty / non-array: clear all rendered rows
|
|
62
70
|
if (!Array.isArray(items)) {
|
|
63
71
|
tmpl.__micraList.forEach(n => n.remove())
|
|
64
72
|
tmpl.__micraList = []
|
|
65
73
|
keyMap.clear()
|
|
66
|
-
|
|
74
|
+
continue
|
|
67
75
|
}
|
|
68
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
|
+
|
|
69
83
|
if (keyAttr) {
|
|
70
|
-
renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap,
|
|
84
|
+
renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, state, rawState, instance, canSkipUnchanged)
|
|
71
85
|
} else {
|
|
72
|
-
renderNoKey(tmpl, items as StateRecord[], marker,
|
|
86
|
+
renderNoKey(tmpl, items as StateRecord[], marker, state, rawState, instance)
|
|
73
87
|
}
|
|
74
|
-
}
|
|
88
|
+
}
|
|
75
89
|
}
|
|
76
90
|
|
|
77
91
|
// ── Keyed diff ────────────────────────────────────────────────────────────────
|
|
@@ -82,10 +96,10 @@ function renderKeyed<S extends StateRecord>(
|
|
|
82
96
|
keyAttr: string,
|
|
83
97
|
marker: Comment,
|
|
84
98
|
keyMap: Map<unknown, MicraElement>,
|
|
85
|
-
parent: Node,
|
|
86
99
|
state: StateRecord,
|
|
87
100
|
rawState: StateRecord,
|
|
88
101
|
instance: InternalInstance<S>,
|
|
102
|
+
canSkipUnchanged: boolean,
|
|
89
103
|
): void {
|
|
90
104
|
const nextKeys = new Set<unknown>()
|
|
91
105
|
const nextNodes: MicraElement[] = []
|
|
@@ -118,16 +132,37 @@ function renderKeyed<S extends StateRecord>(
|
|
|
118
132
|
}
|
|
119
133
|
node.__micraKey = key
|
|
120
134
|
keyMap.set(key, node)
|
|
121
|
-
// Bind data-on
|
|
122
|
-
|
|
123
|
-
|
|
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
|
+
} 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
|
|
124
151
|
}
|
|
125
152
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
|
|
162
|
+
// Use the cached scan if present (created above on first sight of this key);
|
|
163
|
+
// older paths may pass a node we haven't scanned yet.
|
|
164
|
+
const rowScan = node.__micraScan ?? (node.__micraScan = scanComponent(node))
|
|
165
|
+
applyDirectives(rowScan, itemState, rawState, instance)
|
|
131
166
|
nextNodes.push(node)
|
|
132
167
|
}
|
|
133
168
|
|
|
@@ -136,23 +171,81 @@ function renderKeyed<S extends StateRecord>(
|
|
|
136
171
|
if (!nextKeys.has(key)) { node.remove(); keyMap.delete(key) }
|
|
137
172
|
}
|
|
138
173
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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)
|
|
144
193
|
}
|
|
145
194
|
|
|
146
195
|
tmpl.__micraList = nextNodes
|
|
147
196
|
}
|
|
148
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
|
+
|
|
149
243
|
// ── Non-keyed (full re-render) ─────────────────────────────────────────────────
|
|
150
244
|
|
|
151
245
|
function renderNoKey<S extends StateRecord>(
|
|
152
246
|
tmpl: MicraTemplate,
|
|
153
247
|
items: StateRecord[],
|
|
154
248
|
marker: Comment,
|
|
155
|
-
parent: Node,
|
|
156
249
|
state: StateRecord,
|
|
157
250
|
rawState: StateRecord,
|
|
158
251
|
instance: InternalInstance<S>,
|
|
@@ -167,13 +260,16 @@ function renderNoKey<S extends StateRecord>(
|
|
|
167
260
|
Object.create(state) as StateRecord,
|
|
168
261
|
{ item, index, $index: index },
|
|
169
262
|
)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
263
|
+
// Fresh clone each render → fresh scan each render (uncached).
|
|
264
|
+
const fragScan = scanFragment(clone)
|
|
265
|
+
applyDirectives(fragScan, itemState, rawState, instance)
|
|
266
|
+
bindDataOn(fragScan.on, instance)
|
|
267
|
+
bindAtEvents(fragScan.atEvents, instance)
|
|
268
|
+
bindModels(fragScan.model, instance)
|
|
173
269
|
|
|
174
270
|
const nodes = Array.from(clone.childNodes) as MicraElement[]
|
|
175
271
|
nodes.forEach(n => { n.__micraEach = true; frag.append(n) })
|
|
176
272
|
tmpl.__micraList.push(...nodes)
|
|
177
273
|
}
|
|
178
|
-
|
|
274
|
+
marker.after(frag)
|
|
179
275
|
}
|
package/src/dom/events.ts
CHANGED
|
@@ -9,11 +9,18 @@
|
|
|
9
9
|
* LLM NOTE: Every listener attached here is also recorded in
|
|
10
10
|
* instance.__micraListeners so destroy() can remove it cleanly.
|
|
11
11
|
* Re-render skips already-bound elements via per-element __micra* flags.
|
|
12
|
+
*
|
|
13
|
+
* All three binders accept pre-computed element lists from scan.ts —
|
|
14
|
+
* no DOM queries here.
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
|
-
import type {
|
|
17
|
+
import type {
|
|
18
|
+
CachedBinding,
|
|
19
|
+
InternalInstance,
|
|
20
|
+
MicraElement,
|
|
21
|
+
StateRecord,
|
|
22
|
+
} from '../types'
|
|
15
23
|
import { warn } from '../utils/expr'
|
|
16
|
-
import { queryOwn, queryOwnAll, queryAll } from './query'
|
|
17
24
|
|
|
18
25
|
/** @internal Attach a DOM listener and track it on the instance for destroy(). */
|
|
19
26
|
function track<S extends StateRecord>(
|
|
@@ -34,23 +41,16 @@ function track<S extends StateRecord>(
|
|
|
34
41
|
*
|
|
35
42
|
* Supports modifiers: `click.prevent`, `click.stop`, `click.self`.
|
|
36
43
|
*
|
|
44
|
+
* @param els - Pre-computed list of [data-on] elements from scan.ts
|
|
45
|
+
*
|
|
37
46
|
* @example
|
|
38
47
|
* <button data-on="click:save">Save</button>
|
|
39
48
|
* <form data-on="submit.prevent:handleSubmit">
|
|
40
49
|
*/
|
|
41
50
|
export function bindDataOn<S extends StateRecord>(
|
|
42
|
-
|
|
51
|
+
els: Element[],
|
|
43
52
|
instance: InternalInstance<S>,
|
|
44
53
|
): void {
|
|
45
|
-
const isFragment = root.nodeType === 11
|
|
46
|
-
const els = isFragment
|
|
47
|
-
? queryAll(root as unknown as ParentNode, '[data-on]')
|
|
48
|
-
: queryOwn(root, 'data-on')
|
|
49
|
-
|
|
50
|
-
// Include root itself if it carries data-on (e.g., the keyed item IS the button)
|
|
51
|
-
if (!isFragment && (root as HTMLElement).hasAttribute?.('data-on') && !els.includes(root))
|
|
52
|
-
els.unshift(root)
|
|
53
|
-
|
|
54
54
|
for (const el of els) {
|
|
55
55
|
const mEl = el as MicraElement
|
|
56
56
|
if (mEl.__micraEvents) continue
|
|
@@ -81,25 +81,19 @@ export function bindDataOn<S extends StateRecord>(
|
|
|
81
81
|
/**
|
|
82
82
|
* Bind `@event="method"` shorthand attributes (Stimulus-style).
|
|
83
83
|
* Bound once per element via `__micraAtBound` — re-renders are no-ops.
|
|
84
|
-
*
|
|
84
|
+
*
|
|
85
|
+
* @param els - Pre-computed list of elements with at least one @-prefixed attr
|
|
86
|
+
* (from scan.ts — replaces the old `querySelectorAll('*')` walk)
|
|
85
87
|
*
|
|
86
88
|
* @example
|
|
87
89
|
* <button @click="increment">+</button>
|
|
88
90
|
* <form @submit.prevent="handleSubmit">
|
|
89
91
|
*/
|
|
90
92
|
export function bindAtEvents<S extends StateRecord>(
|
|
91
|
-
|
|
93
|
+
els: Element[],
|
|
92
94
|
instance: InternalInstance<S>,
|
|
93
95
|
): void {
|
|
94
|
-
const
|
|
95
|
-
const all = isFragment
|
|
96
|
-
? queryAll(root as unknown as ParentNode, '*')
|
|
97
|
-
: queryOwnAll(root, '*')
|
|
98
|
-
|
|
99
|
-
// Include root itself for the regular-element case
|
|
100
|
-
if (!isFragment && !all.includes(root)) all.unshift(root)
|
|
101
|
-
|
|
102
|
-
for (const el of all) {
|
|
96
|
+
for (const el of els) {
|
|
103
97
|
const mEl = el as MicraElement
|
|
104
98
|
if (mEl.__micraAtBound) continue
|
|
105
99
|
|
|
@@ -133,25 +127,23 @@ export function bindAtEvents<S extends StateRecord>(
|
|
|
133
127
|
* Numeric inputs (`type="number"` / `type="range"`) write numbers, not strings.
|
|
134
128
|
* Checkbox inputs write booleans. Everything else writes strings.
|
|
135
129
|
*
|
|
130
|
+
* @param bindings - Pre-computed model bindings from scan.ts
|
|
131
|
+
* (each carries { el, expr } where expr is the state key)
|
|
132
|
+
*
|
|
136
133
|
* @example
|
|
137
134
|
* <input data-model="search"> // updates state.search on every keystroke
|
|
138
135
|
* <select data-model="sortBy"> // updates state.sortBy on change
|
|
139
136
|
*/
|
|
140
137
|
export function bindModels<S extends StateRecord>(
|
|
141
|
-
|
|
138
|
+
bindings: CachedBinding[],
|
|
142
139
|
instance: InternalInstance<S>,
|
|
143
140
|
): void {
|
|
144
|
-
const
|
|
145
|
-
const els = isFragment
|
|
146
|
-
? queryAll(root as unknown as ParentNode, '[data-model]')
|
|
147
|
-
: queryOwn(root, 'data-model')
|
|
148
|
-
|
|
149
|
-
for (const el of els) {
|
|
141
|
+
for (const { el, expr } of bindings) {
|
|
150
142
|
const mEl = el as MicraElement
|
|
151
143
|
if (mEl.__micraModel) continue
|
|
152
144
|
mEl.__micraModel = true
|
|
153
145
|
|
|
154
|
-
const key = (
|
|
146
|
+
const key = expr.trim()
|
|
155
147
|
const tag = el.tagName
|
|
156
148
|
const inputEl = el as HTMLInputElement
|
|
157
149
|
const inputType = inputEl.type
|
package/src/dom/refs.ts
CHANGED
|
@@ -2,33 +2,33 @@
|
|
|
2
2
|
* src/dom/refs.ts — data-ref collection.
|
|
3
3
|
*
|
|
4
4
|
* Responsibilities:
|
|
5
|
-
* -
|
|
6
|
-
* - Populate `instance.refs` so methods can do `this.refs.chart` etc.
|
|
5
|
+
* - Populate `instance.refs` from a pre-scanned list of [data-ref] elements.
|
|
7
6
|
*
|
|
8
7
|
* LLM NOTE: This module is PURE relative to state — it only reads DOM attributes
|
|
9
8
|
* and writes to instance.refs. It does NOT trigger renders.
|
|
10
9
|
*/
|
|
11
10
|
|
|
12
11
|
import type { InternalInstance, MicraElement, StateRecord } from '../types'
|
|
13
|
-
import { queryOwn } from './query'
|
|
14
12
|
|
|
15
13
|
/**
|
|
16
|
-
*
|
|
17
|
-
* `instance.refs`.
|
|
14
|
+
* Build `instance.refs` from the pre-scanned [data-ref] elements.
|
|
18
15
|
*
|
|
19
16
|
* Called once after the initial render and again on every re-render (refs may
|
|
20
17
|
* point to newly created elements after an each-list update).
|
|
21
18
|
*
|
|
19
|
+
* @param els - List of [data-ref] elements from scan.ts
|
|
20
|
+
*
|
|
22
21
|
* @example
|
|
23
22
|
* // HTML: <canvas data-ref="chart">
|
|
24
23
|
* // JS: this.refs.chart → HTMLCanvasElement
|
|
25
24
|
*/
|
|
26
25
|
export function collectRefs<S extends StateRecord>(
|
|
27
|
-
|
|
26
|
+
els: Element[],
|
|
28
27
|
instance: InternalInstance<S>,
|
|
29
28
|
): void {
|
|
29
|
+
if (!els.length) return
|
|
30
30
|
instance.refs = {}
|
|
31
|
-
for (const el of
|
|
31
|
+
for (const el of els) {
|
|
32
32
|
const name = (el as MicraElement).dataset['ref']
|
|
33
33
|
if (name) instance.refs[name] = el as HTMLElement
|
|
34
34
|
}
|