micra.js 1.0.0 → 2.0.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/src/core/mount.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  ComponentInstance,
18
18
  EventHandler,
19
19
  InternalInstance,
20
+ MicraElement,
20
21
  StateRecord,
21
22
  UnsubFn,
22
23
  } from '../types'
@@ -97,16 +98,46 @@ export function mount<S extends StateRecord>(
97
98
 
98
99
  // Expression state: proxy that falls back to instance methods so expressions
99
100
  // like `data-text="formatDate(item.date)"` can call component methods.
101
+ //
102
+ // Instance methods are returned BOUND to the instance — directive expressions
103
+ // call them as bare identifiers via `with()`, which would normally lose `this`.
104
+ // Bound copies are memoized per method name so repeated reads are cheap.
105
+ //
106
+ // Both traps reject Object.prototype names ('constructor', 'toString', ...) —
107
+ // accessing them via a directive expression returns undefined instead of
108
+ // leaking the prototype.
109
+ const boundMethods = new Map<string, Function>()
100
110
  const exprState = new Proxy(rawState, {
101
111
  get(target, key: string) {
102
- if (key in target) return target[key]
103
- if (key in instance) return instance[key]
112
+ if (Object.prototype.hasOwnProperty.call(target, key)) return target[key]
113
+ if (Object.prototype.hasOwnProperty.call(instance, key) &&
114
+ typeof instance[key] === 'function') {
115
+ const cached = boundMethods.get(key)
116
+ if (cached) return cached
117
+ const bound = (instance[key] as Function).bind(instance)
118
+ boundMethods.set(key, bound)
119
+ return bound
120
+ }
104
121
  return undefined
105
122
  },
123
+ has(target, key: string) {
124
+ if (typeof key !== 'string') return false
125
+ if (Object.prototype.hasOwnProperty.call(target, key)) return true
126
+ return Object.prototype.hasOwnProperty.call(instance, key) &&
127
+ typeof instance[key] === 'function'
128
+ },
106
129
  })
107
130
 
131
+ let warnedReentry = false
108
132
  instance.render = function () {
109
- if (isRendering) return
133
+ if (instance.__micraDestroyed) return
134
+ if (isRendering) {
135
+ if (!warnedReentry) {
136
+ warn('render() re-entry detected — mutation inside a directive expression is ignored. Move state writes to a method.')
137
+ warnedReentry = true
138
+ }
139
+ return
140
+ }
110
141
  isRendering = true
111
142
  try {
112
143
  applyDirectives(root, exprState, rawState, instance)
@@ -122,7 +153,27 @@ export function mount<S extends StateRecord>(
122
153
 
123
154
  // ── Destroy ───────────────────────────────────────────────────────────────
124
155
  instance.destroy = function () {
156
+ if (instance.__micraDestroyed) return
157
+ instance.__micraDestroyed = true
158
+
159
+ // Remove every DOM listener attached by bindDataOn / bindAtEvents / bindModels.
160
+ instance.__micraListeners?.forEach(({ el, type, fn }) => el.removeEventListener(type, fn))
161
+ instance.__micraListeners = []
162
+
163
+ // Clear per-element flags & cached directive scan so a future re-mount of the same DOM works.
164
+ const clearFlags = (el: Element) => {
165
+ const m = el as MicraElement
166
+ delete m.__micraEvents
167
+ delete m.__micraAtBound
168
+ delete m.__micraModel
169
+ delete m.__micraCache
170
+ }
171
+ clearFlags(root)
172
+ root.querySelectorAll('*').forEach(clearFlags)
173
+
125
174
  instance.__micraSubs?.forEach(unsub => unsub())
175
+ instance.__micraSubs = []
176
+
126
177
  if (typeof (definition as Record<string, unknown>).onDestroy === 'function')
127
178
  (definition.onDestroy as () => void).call(instance)
128
179
  _instances.delete(root)
@@ -15,9 +15,12 @@
15
15
 
16
16
  import type {
17
17
  CachedBinding,
18
+ CachedIfBinding,
19
+ CachedPairBinding,
18
20
  DirectiveCache,
19
21
  InternalInstance,
20
22
  MicraElement,
23
+ MicraTemplate,
21
24
  StateRecord,
22
25
  } from '../types'
23
26
  import { evalExpr, warn } from '../utils/expr'
@@ -31,21 +34,60 @@ function applyText(el: Element, expr: string, state: StateRecord): void {
31
34
  if (el.textContent !== text) el.textContent = text
32
35
  }
33
36
 
37
+ /**
38
+ * data-html — writes the expression value as innerHTML.
39
+ *
40
+ * ⚠️ XSS WARNING: the value is rendered as raw HTML. Never bind untrusted
41
+ * input here — use `data-text` (textContent) instead. See docs/directives.md
42
+ * for the full security model.
43
+ */
34
44
  function applyHtml(el: Element, expr: string, state: StateRecord): void {
35
45
  el.innerHTML = String(evalExpr(expr, state) ?? '')
36
46
  }
37
47
 
38
- function applyIf(el: Element, expr: string, state: StateRecord): void {
48
+ /**
49
+ * data-if — true mount/unmount. When the expression is falsy, the element is
50
+ * detached from the DOM and a Comment placeholder takes its slot. When truthy,
51
+ * the element is re-inserted where the placeholder is.
52
+ *
53
+ * Side effect: when an element is detached, its `data-ref` is gone from
54
+ * `this.refs` and its `data-model` listener still exists on the (detached)
55
+ * node — listeners survive detach.
56
+ *
57
+ * Use `data-show` when you want the cheap display:none toggle instead.
58
+ */
59
+ function applyIf(binding: CachedIfBinding, state: StateRecord): void {
60
+ const el = binding.el as HTMLElement
61
+ const truthy = !!evalExpr(binding.expr, state)
62
+ if (truthy) {
63
+ // If a placeholder is currently in the DOM in the element's slot, swap back.
64
+ const ph = binding.placeholder
65
+ if (ph && ph.parentNode) ph.parentNode.replaceChild(el, ph)
66
+ } else {
67
+ // Only detach if currently attached somewhere. Standalone elements
68
+ // (no parent — common in unit tests) are a no-op.
69
+ const parent = el.parentNode
70
+ if (parent) {
71
+ if (!binding.placeholder) binding.placeholder = document.createComment('if')
72
+ parent.replaceChild(binding.placeholder, el)
73
+ }
74
+ }
75
+ }
76
+
77
+ /**
78
+ * data-show — visibility toggle via `style.display`. Element stays in the DOM.
79
+ */
80
+ function applyShow(el: Element, expr: string, state: StateRecord): void {
39
81
  (el as HTMLElement).style.display = evalExpr(expr, state) ? '' : 'none'
40
82
  }
41
83
 
42
- function applyBind(el: Element, expr: string, state: StateRecord): void {
43
- for (const pair of expr.split(',')) {
44
- const colonIdx = pair.indexOf(':')
45
- if (colonIdx === -1) continue
46
- const attr = pair.slice(0, colonIdx).trim()
47
- const valExpr = pair.slice(colonIdx + 1).trim()
48
- const val = evalExpr(valExpr, state)
84
+ function applyBind(
85
+ el: Element,
86
+ pairs: ReadonlyArray<readonly [string, string]>,
87
+ state: StateRecord,
88
+ ): void {
89
+ for (const [attr, valExpr] of pairs) {
90
+ const val = evalExpr(valExpr, state)
49
91
 
50
92
  if (attr === 'class') {
51
93
  (el as HTMLElement).className = String(val ?? '')
@@ -76,26 +118,42 @@ function applyBind(el: Element, expr: string, state: StateRecord): void {
76
118
  * @example
77
119
  * <div data-class="active:tab === 'home', hidden:!loaded">
78
120
  */
79
- function applyClass(el: Element, expr: string, state: StateRecord): void {
80
- for (const pair of expr.split(',')) {
81
- const colonIdx = pair.indexOf(':')
82
- if (colonIdx === -1) continue
83
- const cls = pair.slice(0, colonIdx).trim()
84
- const valExpr = pair.slice(colonIdx + 1).trim()
85
- if (!cls) continue
121
+ function applyClass(
122
+ el: Element,
123
+ pairs: ReadonlyArray<readonly [string, string]>,
124
+ state: StateRecord,
125
+ ): void {
126
+ for (const [cls, valExpr] of pairs) {
86
127
  el.classList.toggle(cls, Boolean(evalExpr(valExpr, state)))
87
128
  }
88
129
  }
89
130
 
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
+
90
145
  function applyModel(
91
146
  el: Element,
92
147
  key: string,
93
148
  rawState: StateRecord,
94
149
  ): void {
95
150
  const html = el as HTMLInputElement
96
- if (document.activeElement !== el) {
97
- html.value = rawState[key] == null ? '' : String(rawState[key])
98
- }
151
+ const stateVal = rawState[key]
152
+ const desired = stateVal == null ? '' : String(stateVal)
153
+ // Only write when out of sync. This is a no-op during live typing (the input
154
+ // event already drove state to match el.value) but still propagates
155
+ // programmatic resets such as `this.state.q = ''` on focused inputs.
156
+ if (html.value !== desired) html.value = desired
99
157
  // listener is attached separately in events.ts — this only syncs the value
100
158
  }
101
159
 
@@ -111,14 +169,16 @@ function buildCache(root: Element): DirectiveCache {
111
169
  .filter(el => !el.closest('template'))
112
170
  .map(el => ({ el, expr: el.getAttribute(attr)! }))
113
171
  }
172
+ const pickPairs = (attr: string): CachedPairBinding[] =>
173
+ pick(attr).map(b => ({ ...b, pairs: parsePairs(b.expr) }))
114
174
  return {
115
175
  text: pick('data-text'),
116
176
  html: pick('data-html'),
117
- if: pick('data-if'),
177
+ if: pick('data-if') as CachedIfBinding[],
118
178
  show: pick('data-show'),
119
- bind: pick('data-bind'),
179
+ bind: pickPairs('data-bind'),
120
180
  model: pick('data-model'),
121
- class: pick('data-class'),
181
+ class: pickPairs('data-class'),
122
182
  }
123
183
  }
124
184
 
@@ -161,13 +221,15 @@ function applyFromList(
161
221
  state: StateRecord,
162
222
  rawState: StateRecord,
163
223
  ): void {
224
+ // data-if runs first so subsequent directives don't write into a tree that's
225
+ // about to be detached this tick.
226
+ cache.if.forEach(b => applyIf(b, state))
164
227
  cache.text.forEach(b => applyText(b.el, b.expr, state))
165
228
  cache.html.forEach(b => applyHtml(b.el, b.expr, state))
166
- cache.if.forEach(b => applyIf(b.el, b.expr, state))
167
- cache.show.forEach(b => applyIf(b.el, b.expr, state))
168
- cache.bind.forEach(b => applyBind(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))
169
231
  cache.model.forEach(b => applyModel(b.el, b.expr.trim(), rawState))
170
- cache.class.forEach(b => applyClass(b.el, b.expr, state))
232
+ cache.class.forEach(b => applyClass(b.el, b.pairs, state))
171
233
  }
172
234
 
173
235
  /** @internal Scan a DocumentFragment (no-key each clone) — returns a DirectiveCache. */
@@ -176,14 +238,16 @@ function buildFragmentList(frag: DocumentFragment): DirectiveCache {
176
238
  queryAll(frag, `[${attr}]`)
177
239
  .filter(el => !el.closest('template'))
178
240
  .map(el => ({ el, expr: el.getAttribute(attr)! }))
241
+ const pickPairs = (attr: string): CachedPairBinding[] =>
242
+ pick(attr).map(b => ({ ...b, pairs: parsePairs(b.expr) }))
179
243
  return {
180
244
  text: pick('data-text'),
181
245
  html: pick('data-html'),
182
- if: pick('data-if'),
246
+ if: pick('data-if') as CachedIfBinding[],
183
247
  show: pick('data-show'),
184
- bind: pick('data-bind'),
248
+ bind: pickPairs('data-bind'),
185
249
  model: pick('data-model'),
186
- class: pick('data-class'),
250
+ class: pickPairs('data-class'),
187
251
  }
188
252
  }
189
253
 
@@ -197,10 +261,24 @@ function buildFragmentList(frag: DocumentFragment): DirectiveCache {
197
261
  */
198
262
  export function validateDirectives(root: Element): void {
199
263
  queryOwn(root, 'data-each').forEach(el => {
200
- if (!el.hasAttribute('data-key')) {
264
+ const tmpl = el as MicraTemplate
265
+ if (!el.hasAttribute('data-key') && !tmpl.__micraNoKeyWarned) {
266
+ tmpl.__micraNoKeyWarned = true
201
267
  warn(`data-each="${el.getAttribute('data-each')}" has no data-key — keyed diff disabled. Add data-key="id" for better performance.`)
202
268
  }
203
269
  })
270
+
271
+ // data-bind="class:..." replaces className wholesale, which fights with
272
+ // 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.`)
280
+ }
281
+ }
204
282
  }
205
283
 
206
284
  // Re-export warn for use in other modules
package/src/dom/each.ts CHANGED
@@ -53,7 +53,10 @@ export function renderList<S extends StateRecord>(
53
53
 
54
54
  const marker = tmpl.__micraMarker
55
55
  const keyMap = tmpl.__micraNodes
56
- const parent = marker.parentNode!
56
+ const parent = marker.parentNode
57
+ // The template (and its marker) is currently detached — likely a data-if
58
+ // ancestor unmounted this subtree. Nothing to do until it returns.
59
+ if (!parent) return
57
60
 
58
61
  // Empty / non-array: clear all rendered rows
59
62
  if (!Array.isArray(items)) {
@@ -86,10 +89,19 @@ function renderKeyed<S extends StateRecord>(
86
89
  ): void {
87
90
  const nextKeys = new Set<unknown>()
88
91
  const nextNodes: MicraElement[] = []
92
+ let warnedNullKey = false
93
+ let warnedDupKey = false
89
94
 
90
95
  for (const [index, item] of items.entries()) {
91
96
  const key = item[keyAttr]
92
- if (key == null) warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`)
97
+ if (key == null && !warnedNullKey) {
98
+ warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`)
99
+ warnedNullKey = true
100
+ }
101
+ if (nextKeys.has(key) && !warnedDupKey) {
102
+ warn(`data-key="${keyAttr}" has duplicate value ${JSON.stringify(key)} — rows will collide`)
103
+ warnedDupKey = true
104
+ }
93
105
  nextKeys.add(key)
94
106
 
95
107
  let node = keyMap.get(key) as MicraElement | undefined
package/src/dom/events.ts CHANGED
@@ -3,15 +3,28 @@
3
3
  *
4
4
  * Responsibilities:
5
5
  * - Bind `data-on="event:method"` listeners (once per element)
6
- * - Bind `@event="method"` shorthand (scanned once per component root)
6
+ * - Bind `@event="method"` shorthand (once per element)
7
+ * - Bind `data-model` two-way input listeners (once per element)
7
8
  *
8
- * LLM NOTE: Listeners are attached exactly once. The `__micraEvents` and
9
- * `__micraAtScanned` flags prevent duplicate bindings on re-renders.
9
+ * LLM NOTE: Every listener attached here is also recorded in
10
+ * instance.__micraListeners so destroy() can remove it cleanly.
11
+ * Re-render skips already-bound elements via per-element __micra* flags.
10
12
  */
11
13
 
12
14
  import type { InternalInstance, MicraElement, StateRecord } from '../types'
13
- import { evalExpr, warn } from '../utils/expr'
14
- import { queryOwn, queryAll } from './query'
15
+ import { warn } from '../utils/expr'
16
+ import { queryOwn, queryOwnAll, queryAll } from './query'
17
+
18
+ /** @internal Attach a DOM listener and track it on the instance for destroy(). */
19
+ function track<S extends StateRecord>(
20
+ instance: InternalInstance<S>,
21
+ el: Element,
22
+ type: string,
23
+ fn: EventListener,
24
+ ): void {
25
+ el.addEventListener(type, fn)
26
+ ;(instance.__micraListeners ??= []).push({ el, type, fn })
27
+ }
15
28
 
16
29
  // ── data-on ───────────────────────────────────────────────────────────────────
17
30
 
@@ -50,7 +63,7 @@ export function bindDataOn<S extends StateRecord>(
50
63
 
51
64
  const [evName, ...mods] = evSpec.split('.')
52
65
 
53
- el.addEventListener(evName!, (e: Event) => {
66
+ track(instance, el, evName!, (e: Event) => {
54
67
  if (mods.includes('prevent')) e.preventDefault()
55
68
  if (mods.includes('stop')) e.stopPropagation()
56
69
  if (mods.includes('self') && e.target !== el) return
@@ -67,7 +80,7 @@ export function bindDataOn<S extends StateRecord>(
67
80
 
68
81
  /**
69
82
  * Bind `@event="method"` shorthand attributes (Stimulus-style).
70
- * Scanned once per component root (guarded by `__micraAtScanned`).
83
+ * Bound once per element via `__micraAtBound` re-renders are no-ops.
71
84
  * Supports the same modifiers as data-on: `@click.prevent="submit"`.
72
85
  *
73
86
  * @example
@@ -78,18 +91,25 @@ export function bindAtEvents<S extends StateRecord>(
78
91
  root: Element,
79
92
  instance: InternalInstance<S>,
80
93
  ): void {
81
- const mRoot = root as MicraElement
82
- if (mRoot.__micraAtScanned) return
83
- mRoot.__micraAtScanned = true
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)
84
101
 
85
- const all = queryAll(root, '*')
86
102
  for (const el of all) {
103
+ const mEl = el as MicraElement
104
+ if (mEl.__micraAtBound) continue
105
+
106
+ let bound = false
87
107
  for (const attr of Array.from(el.attributes)) {
88
108
  if (!attr.name.startsWith('@')) continue
89
109
  const [evSpec, ...rest] = attr.name.slice(1).split('.')
90
110
  const method = attr.value.trim()
91
111
 
92
- el.addEventListener(evSpec!, (e: Event) => {
112
+ track(instance, el, evSpec!, (e: Event) => {
93
113
  if (rest.includes('prevent')) e.preventDefault()
94
114
  if (rest.includes('stop')) e.stopPropagation()
95
115
  if (rest.includes('self') && e.target !== el) return
@@ -98,7 +118,9 @@ export function bindAtEvents<S extends StateRecord>(
98
118
  if (typeof fn === 'function') (fn as (e: Event) => void).call(instance, e)
99
119
  else warn(`method "${method}" not found`)
100
120
  })
121
+ bound = true
101
122
  }
123
+ if (bound) mEl.__micraAtBound = true
102
124
  }
103
125
  }
104
126
 
@@ -108,6 +130,9 @@ export function bindAtEvents<S extends StateRecord>(
108
130
  * Two-way binding: `data-model="key"` wires <input>/<select>/<textarea>
109
131
  * to `state[key]`. Binding is attached once per element.
110
132
  *
133
+ * Numeric inputs (`type="number"` / `type="range"`) write numbers, not strings.
134
+ * Checkbox inputs write booleans. Everything else writes strings.
135
+ *
111
136
  * @example
112
137
  * <input data-model="search"> // updates state.search on every keystroke
113
138
  * <select data-model="sortBy"> // updates state.sortBy on change
@@ -128,18 +153,23 @@ export function bindModels<S extends StateRecord>(
128
153
 
129
154
  const key = (el as HTMLInputElement).dataset['model'] ?? ''
130
155
  const tag = el.tagName
156
+ const inputEl = el as HTMLInputElement
157
+ const inputType = inputEl.type
131
158
 
132
159
  const update = () => {
133
- const val = tag === 'INPUT' && (el as HTMLInputElement).type === 'checkbox'
134
- ? (el as HTMLInputElement).checked
135
- : (el as HTMLInputElement).value
160
+ let val: unknown
161
+ if (tag === 'INPUT' && inputType === 'checkbox') {
162
+ val = inputEl.checked
163
+ } else if (tag === 'INPUT' && (inputType === 'number' || inputType === 'range')) {
164
+ // Empty string → NaN; preserve raw empty as null so state stays "unfilled"
165
+ val = inputEl.value === '' ? null : inputEl.valueAsNumber
166
+ } else {
167
+ val = inputEl.value
168
+ }
136
169
  ;(instance.state as StateRecord)[key] = val
137
170
  }
138
171
 
139
- el.addEventListener(tag === 'SELECT' || (el as HTMLInputElement).type === 'radio'
140
- ? 'change'
141
- : 'input',
142
- update,
143
- )
172
+ const evType = tag === 'SELECT' || inputType === 'radio' ? 'change' : 'input'
173
+ track(instance, el, evType, update)
144
174
  }
145
175
  }
package/src/dom/query.ts CHANGED
@@ -25,7 +25,21 @@ export function queryAll(root: ParentNode, sel: string): Element[] {
25
25
  * owned by that nested component, not by root's component — so we skip it.
26
26
  */
27
27
  export function queryOwn(root: Element, attr: string): Element[] {
28
- return queryAll(root, `[${attr}]`).filter(el => {
28
+ return filterOwn(root, queryAll(root, `[${attr}]`))
29
+ }
30
+
31
+ /**
32
+ * Like queryOwn but accepts an arbitrary CSS selector. Used by bindAtEvents
33
+ * which scans `*` for `@`-prefixed attribute names (no attribute selector exists
34
+ * for those).
35
+ */
36
+ export function queryOwnAll(root: Element, sel: string): Element[] {
37
+ return filterOwn(root, queryAll(root, sel))
38
+ }
39
+
40
+ /** @internal Shared subtree-ownership filter. */
41
+ function filterOwn(root: Element, els: Element[]): Element[] {
42
+ return els.filter(el => {
29
43
  let node: Element | null = el.parentElement
30
44
  while (node && node !== root) {
31
45
  if (node.hasAttribute('data-component')) return false
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  * - SSR-friendly: Micra.start() is safe to call multiple times
18
18
  * - Directive cache: O(1) re-renders after first mount
19
19
  *
20
- * Size target: < 5 KB minified+gzipped
20
+ * Size target: < 5.5 KB minified+gzipped
21
21
  *
22
22
  * @module Micra
23
23
  */
package/src/types.ts CHANGED
@@ -105,12 +105,21 @@ export type ComponentDefinition<S extends StateRecord = StateRecord> = {
105
105
  export interface MicraElement extends HTMLElement {
106
106
  __micraModel?: true // data-model listener bound
107
107
  __micraEvents?: true // data-on listeners bound
108
- __micraAtScanned?: true // @event shorthand scanned (set on component root)
108
+ __micraAtBound?: true // @event shorthand bound (per-element)
109
109
  __micraKey?: unknown // keyed-diff key
110
110
  __micraEach?: true // belongs to a no-key each list
111
111
  __micraCache?: DirectiveCache // cached directive scan result
112
112
  }
113
113
 
114
+ /**
115
+ * @internal A DOM listener tracked for cleanup on destroy().
116
+ */
117
+ export interface TrackedListener {
118
+ el: Element
119
+ type: string
120
+ fn: EventListener
121
+ }
122
+
114
123
  /**
115
124
  * @internal Extended HTMLTemplateElement with keyed-diff state.
116
125
  */
@@ -118,6 +127,7 @@ export interface MicraTemplate extends HTMLTemplateElement {
118
127
  __micraMarker?: Comment
119
128
  __micraNodes: Map<unknown, MicraElement>
120
129
  __micraList: ChildNode[]
130
+ __micraNoKeyWarned?: true
121
131
  }
122
132
 
123
133
  /**
@@ -128,6 +138,26 @@ export interface CachedBinding {
128
138
  expr: string
129
139
  }
130
140
 
141
+ /**
142
+ * @internal data-if binding — like CachedBinding but also carries the
143
+ * placeholder Comment that takes the element's slot in the DOM while the
144
+ * element is detached (unmounted).
145
+ */
146
+ export interface CachedIfBinding extends CachedBinding {
147
+ placeholder?: Comment
148
+ }
149
+
150
+ /**
151
+ * @internal Per-element directive binding with pre-parsed pairs.
152
+ * Used by `data-bind` and `data-class` — both share the
153
+ * `name:expression[, name:expression…]` syntax.
154
+ */
155
+ export interface CachedPairBinding {
156
+ el: Element
157
+ expr: string
158
+ pairs: ReadonlyArray<readonly [string, string]>
159
+ }
160
+
131
161
  /**
132
162
  * @internal Directive scan result — built once per Element, reused every render.
133
163
  * This is the core of the performance optimization.
@@ -138,11 +168,11 @@ export interface CachedBinding {
138
168
  export interface DirectiveCache {
139
169
  text: CachedBinding[]
140
170
  html: CachedBinding[]
141
- if: CachedBinding[]
171
+ if: CachedIfBinding[]
142
172
  show: CachedBinding[]
143
- bind: CachedBinding[]
173
+ bind: CachedPairBinding[]
144
174
  model: CachedBinding[]
145
- class: CachedBinding[]
175
+ class: CachedPairBinding[]
146
176
  }
147
177
 
148
178
  /**
@@ -153,5 +183,7 @@ export interface DirectiveCache {
153
183
  export interface InternalInstance<S extends StateRecord = StateRecord>
154
184
  extends ComponentInstance<S> {
155
185
  __micraSubs?: UnsubFn[]
186
+ __micraListeners?: TrackedListener[]
187
+ __micraDestroyed?: true
156
188
  [key: string]: unknown
157
189
  }