micra.js 2.1.0 → 2.2.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.
@@ -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.
@@ -110,13 +104,8 @@ function applyBind(
110
104
 
111
105
  /**
112
106
  * 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">
107
+ * Toggles classes additively (does NOT replace full className like data-bind:class).
108
+ * Pairs are pre-parsed at scan time.
120
109
  */
121
110
  function applyClass(
122
111
  el: Element,
@@ -128,20 +117,6 @@ function applyClass(
128
117
  }
129
118
  }
130
119
 
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
120
  function applyModel(
146
121
  el: Element,
147
122
  key: string,
@@ -157,126 +132,65 @@ function applyModel(
157
132
  // listener is attached separately in events.ts — this only syncs the value
158
133
  }
159
134
 
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
135
  // ── Main entry point ──────────────────────────────────────────────────────────
186
136
 
187
137
  /**
188
138
  * Apply all non-each directives to a component subtree.
189
139
  *
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.
140
+ * Consumes a pre-computed ScanIndex. data-if runs first so subsequent
141
+ * directives don't write into a tree that's about to be detached this tick.
195
142
  *
196
- * @param root - Component root Element or DocumentFragment (no-key each clone)
143
+ * @param scan - Pre-computed scan from scan.ts (cached per element)
197
144
  * @param state - Expression state (may include item/index for each rows)
198
145
  * @param rawState - Raw (non-proxy) state for model sync
199
- * @param instance - Component instance (unused here, kept for future hooks)
200
146
  */
201
147
  export function applyDirectives<S extends StateRecord>(
202
- root: Element | DocumentFragment,
148
+ scan: ScanIndex,
203
149
  state: StateRecord,
204
150
  rawState: StateRecord,
205
151
  _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
152
  ): void {
224
153
  // data-if runs first so subsequent directives don't write into a tree that's
225
154
  // 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
- }
155
+ for (const b of scan.if) applyIf(b, state)
156
+ for (const b of scan.text) applyText(b.el, b.expr, state)
157
+ for (const b of scan.html) applyHtml(b.el, b.expr, state)
158
+ for (const b of scan.show) applyShow(b.el, b.expr, state)
159
+ for (const b of scan.bind) applyBind(b.el, b.pairs, state)
160
+ for (const b of scan.model) applyModel(b.el, b.expr.trim(), rawState)
161
+ for (const b of scan.class) applyClass(b.el, b.pairs, state)
252
162
  }
253
163
 
254
164
  // ── Dev warning helper ────────────────────────────────────────────────────────
255
165
 
256
166
  /**
257
167
  * Validate directive usage and emit dev warnings.
258
- * Called once after the initial render of a component.
168
+ * Called once after the initial render of a component, with the already-built
169
+ * scan so we don't walk the DOM again.
259
170
  *
260
171
  * @internal
261
172
  */
262
- export function validateDirectives(root: Element): void {
263
- queryOwn(root, 'data-each').forEach(el => {
264
- const tmpl = el as MicraTemplate
173
+ export function validateDirectives(scan: ScanIndex): void {
174
+ for (const el of scan.each) {
175
+ const tmpl = el as HTMLTemplateElement & { __micraNoKeyWarned?: true }
265
176
  if (!el.hasAttribute('data-key') && !tmpl.__micraNoKeyWarned) {
266
177
  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.`)
178
+ warn(
179
+ `data-each="${el.getAttribute('data-each')}" has no data-key — ` +
180
+ `keyed diff disabled. Add data-key="id" for better performance.`,
181
+ )
268
182
  }
269
- })
183
+ }
270
184
 
271
185
  // data-bind="class:..." replaces className wholesale, which fights with
272
186
  // 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.`)
187
+ for (const b of scan.bind) {
188
+ const hasClassBind = b.pairs.some(p => p[0] === 'class')
189
+ if (hasClassBind && b.el.hasAttribute('data-class')) {
190
+ warn(
191
+ `element has both data-bind="class:..." and data-class they fight ` +
192
+ `on every render. Use one.`,
193
+ )
280
194
  }
281
195
  }
282
196
  }
package/src/dom/each.ts CHANGED
@@ -8,34 +8,41 @@
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
33
+ * @param templates - Pre-scanned list of <template data-each> elements
27
34
  * @param state - Expression state (proxy merging rawState + instance)
28
35
  * @param rawState - Raw (non-proxy) state — used for model binding
29
36
  * @param instance - Component instance (for event binding)
30
37
  */
31
38
  export function renderList<S extends StateRecord>(
32
- root: Element,
39
+ templates: Element[],
33
40
  state: StateRecord,
34
41
  rawState: StateRecord,
35
42
  instance: InternalInstance<S>,
36
43
  ): void {
37
- queryOwn(root, 'data-each').forEach(tmplEl => {
38
- if (tmplEl.tagName !== 'TEMPLATE') return
44
+ for (const tmplEl of templates) {
45
+ if (tmplEl.tagName !== 'TEMPLATE') continue
39
46
  const tmpl = tmplEl as MicraTemplate
40
47
 
41
48
  const itemsExpr = tmpl.getAttribute('data-each')!
@@ -56,14 +63,14 @@ export function renderList<S extends StateRecord>(
56
63
  const parent = marker.parentNode
57
64
  // The template (and its marker) is currently detached — likely a data-if
58
65
  // ancestor unmounted this subtree. Nothing to do until it returns.
59
- if (!parent) return
66
+ if (!parent) continue
60
67
 
61
68
  // Empty / non-array: clear all rendered rows
62
69
  if (!Array.isArray(items)) {
63
70
  tmpl.__micraList.forEach(n => n.remove())
64
71
  tmpl.__micraList = []
65
72
  keyMap.clear()
66
- return
73
+ continue
67
74
  }
68
75
 
69
76
  if (keyAttr) {
@@ -71,7 +78,7 @@ export function renderList<S extends StateRecord>(
71
78
  } else {
72
79
  renderNoKey(tmpl, items as StateRecord[], marker, parent, state, rawState, instance)
73
80
  }
74
- })
81
+ }
75
82
  }
76
83
 
77
84
  // ── Keyed diff ────────────────────────────────────────────────────────────────
@@ -118,16 +125,23 @@ function renderKeyed<S extends StateRecord>(
118
125
  }
119
126
  node.__micraKey = key
120
127
  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)
128
+ // Bind data-on / @event / data-model listeners once per row node.
129
+ // Scan the row, cache the scan on the node for future re-renders.
130
+ const rowScan = scanComponent(node)
131
+ node.__micraScan = rowScan
132
+ bindDataOn(rowScan.on, instance)
133
+ bindAtEvents(rowScan.atEvents, instance)
134
+ bindModels(rowScan.model, instance)
124
135
  }
125
136
 
126
137
  const itemState = Object.assign(
127
138
  Object.create(state) as StateRecord,
128
139
  { item, index, $index: index },
129
140
  )
130
- applyDirectives(node, itemState, rawState, instance)
141
+ // Use the cached scan if present (created above on first sight of this key);
142
+ // older paths may pass a node we haven't scanned yet.
143
+ const rowScan = node.__micraScan ?? (node.__micraScan = scanComponent(node))
144
+ applyDirectives(rowScan, itemState, rawState, instance)
131
145
  nextNodes.push(node)
132
146
  }
133
147
 
@@ -167,9 +181,12 @@ function renderNoKey<S extends StateRecord>(
167
181
  Object.create(state) as StateRecord,
168
182
  { item, index, $index: index },
169
183
  )
170
- applyDirectives(clone, itemState, rawState, instance)
171
- bindDataOn(clone as unknown as Element, instance)
172
- bindAtEvents(clone as unknown as Element, instance)
184
+ // Fresh clone each render → fresh scan each render (uncached).
185
+ const fragScan = scanFragment(clone)
186
+ applyDirectives(fragScan, itemState, rawState, instance)
187
+ bindDataOn(fragScan.on, instance)
188
+ bindAtEvents(fragScan.atEvents, instance)
189
+ bindModels(fragScan.model, instance)
173
190
 
174
191
  const nodes = Array.from(clone.childNodes) as MicraElement[]
175
192
  nodes.forEach(n => { n.__micraEach = true; frag.append(n) })
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,32 @@
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 {
30
29
  instance.refs = {}
31
- for (const el of queryOwn(root, 'data-ref')) {
30
+ for (const el of els) {
32
31
  const name = (el as MicraElement).dataset['ref']
33
32
  if (name) instance.refs[name] = el as HTMLElement
34
33
  }