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.
@@ -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. The directive cache
10
- * (DirectiveCache on el.__micraCache) avoids repeated querySelectorAll on
11
- * re-renders cache is built lazily on the first call for each root element.
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
- MicraElement,
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
- el.innerHTML = String(evalExpr(expr, state) ?? '')
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
- (el as HTMLElement).style.display = evalExpr(expr, state) ? '' : 'none'
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
- * Parses comma-separated `className:expression` pairs and toggles classes additively.
114
- * Unlike data-bind="class:expr" this does NOT replace the full className.
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
- * For regular Elements: directive bindings are cached in `el.__micraCache`
191
- * after the first call subsequent re-renders skip querySelectorAll entirely.
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 root - Component root Element or DocumentFragment (no-key each clone)
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
- root: Element | DocumentFragment,
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
- cache.if.forEach(b => applyIf(b, state))
227
- cache.text.forEach(b => applyText(b.el, b.expr, state))
228
- cache.html.forEach(b => applyHtml(b.el, b.expr, state))
229
- cache.show.forEach(b => applyShow(b.el, b.expr, state))
230
- cache.bind.forEach(b => applyBind(b.el, b.pairs, state))
231
- cache.model.forEach(b => applyModel(b.el, b.expr.trim(), rawState))
232
- cache.class.forEach(b => applyClass(b.el, b.pairs, state))
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(root: Element): void {
263
- queryOwn(root, 'data-each').forEach(el => {
264
- const tmpl = el as MicraTemplate
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(`data-each="${el.getAttribute('data-each')}" has no data-key — keyed diff disabled. Add data-key="id" for better performance.`)
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 bindEls = queryOwn(root, 'data-bind')
274
- if ((root as HTMLElement).hasAttribute?.('data-bind') && !bindEls.includes(root)) bindEls.unshift(root)
275
- for (const el of bindEls) {
276
- const spec = el.getAttribute('data-bind') ?? ''
277
- const hasClassBind = spec.split(',').some(p => p.trim().split(':')[0]?.trim() === 'class')
278
- if (hasClassBind && el.hasAttribute('data-class')) {
279
- warn(`element has both data-bind="class:..." and data-class — they fight on every render. Use one.`)
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
- * Only <template> elements with data-each are processed.
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 { InternalInstance, MicraElement, MicraTemplate, StateRecord } from '../types'
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 { queryOwn, queryAll } from './query'
26
+ import { bindDataOn, bindAtEvents, bindModels } from './events'
27
+ import { scanComponent, scanFragment } from './scan'
21
28
 
22
29
  /**
23
- * Process all `<template data-each>` elements owned by `root`.
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 root - Component root Element
27
- * @param state - Expression state (proxy merging rawState + instance)
28
- * @param rawState - Raw (non-proxy) state — used for model binding
29
- * @param instance - Component instance (for event binding)
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
- root: Element,
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
- queryOwn(root, 'data-each').forEach(tmplEl => {
38
- if (tmplEl.tagName !== 'TEMPLATE') return
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 (!parent) return
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
- return
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, parent, state, rawState, instance)
84
+ renderKeyed(tmpl, items as StateRecord[], keyAttr, marker, keyMap, state, rawState, instance, canSkipUnchanged)
71
85
  } else {
72
- renderNoKey(tmpl, items as StateRecord[], marker, parent, state, rawState, instance)
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 and @event handlers on the freshly created node (once)
122
- bindDataOn(node, instance)
123
- bindAtEvents(node, instance)
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
- const itemState = Object.assign(
127
- Object.create(state) as StateRecord,
128
- { item, index, $index: index },
129
- )
130
- applyDirectives(node, itemState, rawState, instance)
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
- // Insert / reorder nodes after marker (insertBefore is no-op if already in place)
140
- let cursor: Node = marker
141
- for (const node of nextNodes) {
142
- if (cursor.nextSibling !== node) parent.insertBefore(node, cursor.nextSibling)
143
- cursor = node
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
- applyDirectives(clone, itemState, rawState, instance)
171
- bindDataOn(clone as unknown as Element, instance)
172
- bindAtEvents(clone as unknown as Element, instance)
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
- parent.insertBefore(frag, marker.nextSibling)
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 { InternalInstance, MicraElement, StateRecord } from '../types'
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
- root: Element,
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
- * Supports the same modifiers as data-on: `@click.prevent="submit"`.
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
- root: Element,
93
+ els: Element[],
92
94
  instance: InternalInstance<S>,
93
95
  ): void {
94
- const isFragment = root.nodeType === 11
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
- root: Element,
138
+ bindings: CachedBinding[],
142
139
  instance: InternalInstance<S>,
143
140
  ): void {
144
- const isFragment = root.nodeType === 11
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 = (el as HTMLInputElement).dataset['model'] ?? ''
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
- * - After each render, scan for `[data-ref]` elements (owned by this component)
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
- * Collect all `[data-ref="name"]` elements owned by this component root into
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
- root: Element,
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 queryOwn(root, 'data-ref')) {
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
  }