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/README.md CHANGED
@@ -27,7 +27,7 @@ Use it when you want:
27
27
  <button @click="increment">+</button>
28
28
  </div>
29
29
 
30
- <script src="https://unpkg.com/micra.js/dist/micra.min.js"></script>
30
+ <script src="https://cdn.jsdelivr.net/npm/micra.js/dist/micra.min.js"></script>
31
31
  <script>
32
32
  Micra.define('counter', {
33
33
  state: { count: 0 },
@@ -44,7 +44,7 @@ Use it when you want:
44
44
  ### CDN
45
45
 
46
46
  ```html
47
- <script src="https://unpkg.com/micra.js/dist/micra.min.js"></script>
47
+ <script src="https://cdn.jsdelivr.net/npm/micra.js/dist/micra.min.js"></script>
48
48
  ```
49
49
 
50
50
  ### npm
@@ -54,7 +54,7 @@ npm install micra.js
54
54
  ```
55
55
 
56
56
  ```ts
57
- import * as Micra from 'micra'
57
+ import * as Micra from 'micra.js'
58
58
  ```
59
59
 
60
60
  ## Basic usage
@@ -71,7 +71,7 @@ A counter mounted automatically from `data-component`:
71
71
  ```
72
72
 
73
73
  ```ts
74
- import * as Micra from 'micra'
74
+ import * as Micra from 'micra.js'
75
75
 
76
76
  Micra.define('counter', {
77
77
  state: { count: 0 },
@@ -164,3 +164,6 @@ this.on(event, handler)
164
164
  - [SSR](./docs/ssr.md)
165
165
  - [Examples](./docs/examples.md)
166
166
  - [API reference](./docs/api-reference.md)
167
+ - Recipes:
168
+ - [Todo app](./docs/recipes/todo-app.md)
169
+ - [Server-sent events (SSE)](./docs/recipes/sse.md)
@@ -3,10 +3,12 @@
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
  import type { InternalInstance, StateRecord } from '../types';
12
14
  /**
@@ -22,7 +24,7 @@ import type { InternalInstance, StateRecord } from '../types';
22
24
  export declare function bindDataOn<S extends StateRecord>(root: Element, instance: InternalInstance<S>): void;
23
25
  /**
24
26
  * Bind `@event="method"` shorthand attributes (Stimulus-style).
25
- * Scanned once per component root (guarded by `__micraAtScanned`).
27
+ * Bound once per element via `__micraAtBound` re-renders are no-ops.
26
28
  * Supports the same modifiers as data-on: `@click.prevent="submit"`.
27
29
  *
28
30
  * @example
@@ -34,6 +36,9 @@ export declare function bindAtEvents<S extends StateRecord>(root: Element, insta
34
36
  * Two-way binding: `data-model="key"` wires <input>/<select>/<textarea>
35
37
  * to `state[key]`. Binding is attached once per element.
36
38
  *
39
+ * Numeric inputs (`type="number"` / `type="range"`) write numbers, not strings.
40
+ * Checkbox inputs write booleans. Everything else writes strings.
41
+ *
37
42
  * @example
38
43
  * <input data-model="search"> // updates state.search on every keystroke
39
44
  * <select data-model="sortBy"> // updates state.sortBy on change
@@ -21,3 +21,9 @@ export declare function queryAll(root: ParentNode, sel: string): Element[];
21
21
  * owned by that nested component, not by root's component — so we skip it.
22
22
  */
23
23
  export declare function queryOwn(root: Element, attr: string): Element[];
24
+ /**
25
+ * Like queryOwn but accepts an arbitrary CSS selector. Used by bindAtEvents
26
+ * which scans `*` for `@`-prefixed attribute names (no attribute selector exists
27
+ * for those).
28
+ */
29
+ export declare function queryOwnAll(root: Element, sel: string): Element[];
package/dist/index.d.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/dist/micra.cjs.js CHANGED
@@ -1,4 +1,4 @@
1
- /* Micra.js v1.0.0 — https://github.com/micra-js/micra — MIT */
1
+ /* Micra.js v2.0.0 — https://github.com/micra-js/micra — MIT */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -66,9 +66,9 @@ async function micraFetch(url, options = {}) {
66
66
  }
67
67
  if (Object.keys(params).length)
68
68
  finalUrl += (url.includes("?") ? "&" : "?") + new URLSearchParams(params);
69
- } else {
69
+ } else if (options.body !== void 0) {
70
70
  headers["Content-Type"] = "application/json";
71
- body = JSON.stringify(options.body !== void 0 ? options.body : options);
71
+ body = JSON.stringify(options.body);
72
72
  }
73
73
  const res = await fetch(finalUrl, {
74
74
  method,
@@ -115,10 +115,69 @@ function debug() {
115
115
 
116
116
  // src/utils/expr.ts
117
117
  var exprCache = /* @__PURE__ */ new Map();
118
+ var warnedRuntime = /* @__PURE__ */ new Set();
118
119
  var SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/;
120
+ var ALLOWED_GLOBALS = /* @__PURE__ */ new Set([
121
+ "Math",
122
+ "JSON",
123
+ "Date",
124
+ "String",
125
+ "Number",
126
+ "Boolean",
127
+ "Array",
128
+ "Object",
129
+ "parseInt",
130
+ "parseFloat",
131
+ "isNaN",
132
+ "isFinite",
133
+ "NaN",
134
+ "Infinity",
135
+ "undefined"
136
+ ]);
137
+ var PARAM_S = "$s";
138
+ var PARAM_SAFE = "$safe";
139
+ var SAFE_OUTER = new Proxy(/* @__PURE__ */ Object.create(null), {
140
+ has(_target, key) {
141
+ if (typeof key !== "string") return false;
142
+ if (key === PARAM_S || key === PARAM_SAFE) return false;
143
+ return !ALLOWED_GLOBALS.has(key);
144
+ },
145
+ get() {
146
+ return void 0;
147
+ }
148
+ });
149
+ var safeWrapCache = /* @__PURE__ */ new WeakMap();
150
+ var OBJ_PROTO_KEYS = new Set(Object.getOwnPropertyNames(Object.prototype));
151
+ function safeStateWrap(state) {
152
+ const cached = safeWrapCache.get(state);
153
+ if (cached) return cached;
154
+ const wrapped = new Proxy(state, {
155
+ has(target, key) {
156
+ return safeStateHas(target, key);
157
+ },
158
+ get(target, key) {
159
+ return Reflect.get(target, key);
160
+ }
161
+ });
162
+ safeWrapCache.set(state, wrapped);
163
+ return wrapped;
164
+ }
165
+ function safeStateHas(state, key) {
166
+ if (typeof key !== "string") return false;
167
+ if (!Reflect.has(state, key)) return false;
168
+ if (!OBJ_PROTO_KEYS.has(key)) return true;
169
+ let obj = state;
170
+ while (obj && obj !== Object.prototype) {
171
+ if (Object.prototype.hasOwnProperty.call(obj, key)) return true;
172
+ obj = Object.getPrototypeOf(obj);
173
+ }
174
+ return false;
175
+ }
119
176
  function evalExpr(expr, state) {
120
177
  if (SIMPLE_PATH.test(expr)) {
121
- return expr.split(".").reduce(
178
+ const parts = expr.split(".");
179
+ if (!safeStateHas(state, parts[0])) return void 0;
180
+ return parts.reduce(
122
181
  (obj, key) => obj != null ? obj[key] : void 0,
123
182
  state
124
183
  );
@@ -127,7 +186,7 @@ function evalExpr(expr, state) {
127
186
  try {
128
187
  exprCache.set(
129
188
  expr,
130
- new Function("$s", `with($s){return (${expr})}`)
189
+ new Function("$s", "$safe", `with($safe){with($s){return (${expr})}}`)
131
190
  );
132
191
  } catch {
133
192
  warn(`invalid expression "${expr}"`);
@@ -135,8 +194,12 @@ function evalExpr(expr, state) {
135
194
  }
136
195
  }
137
196
  try {
138
- return exprCache.get(expr)(state);
139
- } catch {
197
+ return exprCache.get(expr)(safeStateWrap(state), SAFE_OUTER);
198
+ } catch (e) {
199
+ if (!warnedRuntime.has(expr)) {
200
+ warnedRuntime.add(expr);
201
+ warn(`runtime error in "${expr}": ${e.message}`);
202
+ }
140
203
  return void 0;
141
204
  }
142
205
  }
@@ -152,8 +215,10 @@ function on(event, handler) {
152
215
  return () => off(event, handler);
153
216
  }
154
217
  function off(event, handler) {
155
- var _a;
156
- (_a = _bus.get(event)) == null ? void 0 : _a.delete(handler);
218
+ const set = _bus.get(event);
219
+ if (!set) return;
220
+ set.delete(handler);
221
+ if (set.size === 0) _bus.delete(event);
157
222
  }
158
223
  function emit(event, payload) {
159
224
  var _a;
@@ -194,7 +259,13 @@ function queryAll(root, sel) {
194
259
  return Array.from(root.querySelectorAll(sel));
195
260
  }
196
261
  function queryOwn(root, attr) {
197
- return queryAll(root, `[${attr}]`).filter((el) => {
262
+ return filterOwn(root, queryAll(root, `[${attr}]`));
263
+ }
264
+ function queryOwnAll(root, sel) {
265
+ return filterOwn(root, queryAll(root, sel));
266
+ }
267
+ function filterOwn(root, els) {
268
+ return els.filter((el) => {
198
269
  let node = el.parentElement;
199
270
  while (node && node !== root) {
200
271
  if (node.hasAttribute("data-component")) return false;
@@ -214,15 +285,25 @@ function applyHtml(el, expr, state) {
214
285
  var _a;
215
286
  el.innerHTML = String((_a = evalExpr(expr, state)) != null ? _a : "");
216
287
  }
217
- function applyIf(el, expr, state) {
288
+ function applyIf(binding, state) {
289
+ const el = binding.el;
290
+ const truthy = !!evalExpr(binding.expr, state);
291
+ if (truthy) {
292
+ const ph = binding.placeholder;
293
+ if (ph && ph.parentNode) ph.parentNode.replaceChild(el, ph);
294
+ } else {
295
+ const parent = el.parentNode;
296
+ if (parent) {
297
+ if (!binding.placeholder) binding.placeholder = document.createComment("if");
298
+ parent.replaceChild(binding.placeholder, el);
299
+ }
300
+ }
301
+ }
302
+ function applyShow(el, expr, state) {
218
303
  el.style.display = evalExpr(expr, state) ? "" : "none";
219
304
  }
220
- function applyBind(el, expr, state) {
221
- for (const pair of expr.split(",")) {
222
- const colonIdx = pair.indexOf(":");
223
- if (colonIdx === -1) continue;
224
- const attr = pair.slice(0, colonIdx).trim();
225
- const valExpr = pair.slice(colonIdx + 1).trim();
305
+ function applyBind(el, pairs, state) {
306
+ for (const [attr, valExpr] of pairs) {
226
307
  const val = evalExpr(valExpr, state);
227
308
  if (attr === "class") {
228
309
  el.className = String(val != null ? val : "");
@@ -242,21 +323,28 @@ function applyBind(el, expr, state) {
242
323
  }
243
324
  }
244
325
  }
245
- function applyClass(el, expr, state) {
246
- for (const pair of expr.split(",")) {
247
- const colonIdx = pair.indexOf(":");
248
- if (colonIdx === -1) continue;
249
- const cls = pair.slice(0, colonIdx).trim();
250
- const valExpr = pair.slice(colonIdx + 1).trim();
251
- if (!cls) continue;
326
+ function applyClass(el, pairs, state) {
327
+ for (const [cls, valExpr] of pairs) {
252
328
  el.classList.toggle(cls, Boolean(evalExpr(valExpr, state)));
253
329
  }
254
330
  }
331
+ function parsePairs(expr) {
332
+ const out = [];
333
+ for (const part of expr.split(",")) {
334
+ const colonIdx = part.indexOf(":");
335
+ if (colonIdx === -1) continue;
336
+ const left = part.slice(0, colonIdx).trim();
337
+ const right = part.slice(colonIdx + 1).trim();
338
+ if (!left) continue;
339
+ out.push([left, right]);
340
+ }
341
+ return out;
342
+ }
255
343
  function applyModel(el, key, rawState) {
256
344
  const html = el;
257
- if (document.activeElement !== el) {
258
- html.value = rawState[key] == null ? "" : String(rawState[key]);
259
- }
345
+ const stateVal = rawState[key];
346
+ const desired = stateVal == null ? "" : String(stateVal);
347
+ if (html.value !== desired) html.value = desired;
260
348
  }
261
349
  function buildCache(root) {
262
350
  const pick = (attr) => {
@@ -265,14 +353,15 @@ function buildCache(root) {
265
353
  if ((_a = root.hasAttribute) == null ? void 0 : _a.call(root, attr)) els.unshift(root);
266
354
  return els.filter((el) => !el.closest("template")).map((el) => ({ el, expr: el.getAttribute(attr) }));
267
355
  };
356
+ const pickPairs = (attr) => pick(attr).map((b) => ({ ...b, pairs: parsePairs(b.expr) }));
268
357
  return {
269
358
  text: pick("data-text"),
270
359
  html: pick("data-html"),
271
360
  if: pick("data-if"),
272
361
  show: pick("data-show"),
273
- bind: pick("data-bind"),
362
+ bind: pickPairs("data-bind"),
274
363
  model: pick("data-model"),
275
- class: pick("data-class")
364
+ class: pickPairs("data-class")
276
365
  };
277
366
  }
278
367
  function applyDirectives(root, state, rawState, _instance) {
@@ -285,35 +374,56 @@ function applyDirectives(root, state, rawState, _instance) {
285
374
  applyFromList(el.__micraCache, state, rawState);
286
375
  }
287
376
  function applyFromList(cache, state, rawState) {
377
+ cache.if.forEach((b) => applyIf(b, state));
288
378
  cache.text.forEach((b) => applyText(b.el, b.expr, state));
289
379
  cache.html.forEach((b) => applyHtml(b.el, b.expr, state));
290
- cache.if.forEach((b) => applyIf(b.el, b.expr, state));
291
- cache.show.forEach((b) => applyIf(b.el, b.expr, state));
292
- cache.bind.forEach((b) => applyBind(b.el, b.expr, state));
380
+ cache.show.forEach((b) => applyShow(b.el, b.expr, state));
381
+ cache.bind.forEach((b) => applyBind(b.el, b.pairs, state));
293
382
  cache.model.forEach((b) => applyModel(b.el, b.expr.trim(), rawState));
294
- cache.class.forEach((b) => applyClass(b.el, b.expr, state));
383
+ cache.class.forEach((b) => applyClass(b.el, b.pairs, state));
295
384
  }
296
385
  function buildFragmentList(frag) {
297
386
  const pick = (attr) => queryAll(frag, `[${attr}]`).filter((el) => !el.closest("template")).map((el) => ({ el, expr: el.getAttribute(attr) }));
387
+ const pickPairs = (attr) => pick(attr).map((b) => ({ ...b, pairs: parsePairs(b.expr) }));
298
388
  return {
299
389
  text: pick("data-text"),
300
390
  html: pick("data-html"),
301
391
  if: pick("data-if"),
302
392
  show: pick("data-show"),
303
- bind: pick("data-bind"),
393
+ bind: pickPairs("data-bind"),
304
394
  model: pick("data-model"),
305
- class: pick("data-class")
395
+ class: pickPairs("data-class")
306
396
  };
307
397
  }
308
398
  function validateDirectives(root) {
399
+ var _a, _b;
309
400
  queryOwn(root, "data-each").forEach((el) => {
310
- if (!el.hasAttribute("data-key")) {
401
+ const tmpl = el;
402
+ if (!el.hasAttribute("data-key") && !tmpl.__micraNoKeyWarned) {
403
+ tmpl.__micraNoKeyWarned = true;
311
404
  warn(`data-each="${el.getAttribute("data-each")}" has no data-key \u2014 keyed diff disabled. Add data-key="id" for better performance.`);
312
405
  }
313
406
  });
407
+ const bindEls = queryOwn(root, "data-bind");
408
+ if (((_a = root.hasAttribute) == null ? void 0 : _a.call(root, "data-bind")) && !bindEls.includes(root)) bindEls.unshift(root);
409
+ for (const el of bindEls) {
410
+ const spec = (_b = el.getAttribute("data-bind")) != null ? _b : "";
411
+ const hasClassBind = spec.split(",").some((p) => {
412
+ var _a2;
413
+ return ((_a2 = p.trim().split(":")[0]) == null ? void 0 : _a2.trim()) === "class";
414
+ });
415
+ if (hasClassBind && el.hasAttribute("data-class")) {
416
+ warn(`element has both data-bind="class:..." and data-class \u2014 they fight on every render. Use one.`);
417
+ }
418
+ }
314
419
  }
315
420
 
316
421
  // src/dom/events.ts
422
+ function track(instance, el, type, fn) {
423
+ var _a;
424
+ el.addEventListener(type, fn);
425
+ ((_a = instance.__micraListeners) != null ? _a : instance.__micraListeners = []).push({ el, type, fn });
426
+ }
317
427
  function bindDataOn(root, instance) {
318
428
  var _a, _b;
319
429
  const isFragment = root.nodeType === 11;
@@ -329,7 +439,7 @@ function bindDataOn(root, instance) {
329
439
  const [evSpec, method] = part.trim().split(":");
330
440
  if (!evSpec || !method) continue;
331
441
  const [evName, ...mods] = evSpec.split(".");
332
- el.addEventListener(evName, (e) => {
442
+ track(instance, el, evName, (e) => {
333
443
  if (mods.includes("prevent")) e.preventDefault();
334
444
  if (mods.includes("stop")) e.stopPropagation();
335
445
  if (mods.includes("self") && e.target !== el) return;
@@ -341,16 +451,18 @@ function bindDataOn(root, instance) {
341
451
  }
342
452
  }
343
453
  function bindAtEvents(root, instance) {
344
- const mRoot = root;
345
- if (mRoot.__micraAtScanned) return;
346
- mRoot.__micraAtScanned = true;
347
- const all = queryAll(root, "*");
454
+ const isFragment = root.nodeType === 11;
455
+ const all = isFragment ? queryAll(root, "*") : queryOwnAll(root, "*");
456
+ if (!isFragment && !all.includes(root)) all.unshift(root);
348
457
  for (const el of all) {
458
+ const mEl = el;
459
+ if (mEl.__micraAtBound) continue;
460
+ let bound = false;
349
461
  for (const attr of Array.from(el.attributes)) {
350
462
  if (!attr.name.startsWith("@")) continue;
351
463
  const [evSpec, ...rest] = attr.name.slice(1).split(".");
352
464
  const method = attr.value.trim();
353
- el.addEventListener(evSpec, (e) => {
465
+ track(instance, el, evSpec, (e) => {
354
466
  if (rest.includes("prevent")) e.preventDefault();
355
467
  if (rest.includes("stop")) e.stopPropagation();
356
468
  if (rest.includes("self") && e.target !== el) return;
@@ -358,7 +470,9 @@ function bindAtEvents(root, instance) {
358
470
  if (typeof fn === "function") fn.call(instance, e);
359
471
  else warn(`method "${method}" not found`);
360
472
  });
473
+ bound = true;
361
474
  }
475
+ if (bound) mEl.__micraAtBound = true;
362
476
  }
363
477
  }
364
478
  function bindModels(root, instance) {
@@ -371,14 +485,22 @@ function bindModels(root, instance) {
371
485
  mEl.__micraModel = true;
372
486
  const key = (_a = el.dataset["model"]) != null ? _a : "";
373
487
  const tag = el.tagName;
488
+ const inputEl = el;
489
+ const inputType = inputEl.type;
374
490
  const update = () => {
375
- const val = tag === "INPUT" && el.type === "checkbox" ? el.checked : el.value;
491
+ let val;
492
+ if (tag === "INPUT" && inputType === "checkbox") {
493
+ val = inputEl.checked;
494
+ } else if (tag === "INPUT" && (inputType === "number" || inputType === "range")) {
495
+ val = inputEl.value === "" ? null : inputEl.valueAsNumber;
496
+ } else {
497
+ val = inputEl.value;
498
+ }
499
+ ;
376
500
  instance.state[key] = val;
377
501
  };
378
- el.addEventListener(
379
- tag === "SELECT" || el.type === "radio" ? "change" : "input",
380
- update
381
- );
502
+ const evType = tag === "SELECT" || inputType === "radio" ? "change" : "input";
503
+ track(instance, el, evType, update);
382
504
  }
383
505
  }
384
506
 
@@ -401,6 +523,7 @@ function renderList(root, state, rawState, instance) {
401
523
  const marker = tmpl.__micraMarker;
402
524
  const keyMap = tmpl.__micraNodes;
403
525
  const parent = marker.parentNode;
526
+ if (!parent) return;
404
527
  if (!Array.isArray(items)) {
405
528
  tmpl.__micraList.forEach((n) => n.remove());
406
529
  tmpl.__micraList = [];
@@ -417,9 +540,18 @@ function renderList(root, state, rawState, instance) {
417
540
  function renderKeyed(tmpl, items, keyAttr, marker, keyMap, parent, state, rawState, instance) {
418
541
  const nextKeys = /* @__PURE__ */ new Set();
419
542
  const nextNodes = [];
543
+ let warnedNullKey = false;
544
+ let warnedDupKey = false;
420
545
  for (const [index, item] of items.entries()) {
421
546
  const key = item[keyAttr];
422
- if (key == null) warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`);
547
+ if (key == null && !warnedNullKey) {
548
+ warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`);
549
+ warnedNullKey = true;
550
+ }
551
+ if (nextKeys.has(key) && !warnedDupKey) {
552
+ warn(`data-key="${keyAttr}" has duplicate value ${JSON.stringify(key)} \u2014 rows will collide`);
553
+ warnedDupKey = true;
554
+ }
423
555
  nextKeys.add(key);
424
556
  let node = keyMap.get(key);
425
557
  if (!node) {
@@ -522,15 +654,35 @@ function mount(selector, definition) {
522
654
  let isRendering = false;
523
655
  const schedule = createScheduler(() => instance.render());
524
656
  instance.state = createReactiveState(rawState, schedule);
657
+ const boundMethods = /* @__PURE__ */ new Map();
525
658
  const exprState = new Proxy(rawState, {
526
659
  get(target, key) {
527
- if (key in target) return target[key];
528
- if (key in instance) return instance[key];
660
+ if (Object.prototype.hasOwnProperty.call(target, key)) return target[key];
661
+ if (Object.prototype.hasOwnProperty.call(instance, key) && typeof instance[key] === "function") {
662
+ const cached = boundMethods.get(key);
663
+ if (cached) return cached;
664
+ const bound = instance[key].bind(instance);
665
+ boundMethods.set(key, bound);
666
+ return bound;
667
+ }
529
668
  return void 0;
669
+ },
670
+ has(target, key) {
671
+ if (typeof key !== "string") return false;
672
+ if (Object.prototype.hasOwnProperty.call(target, key)) return true;
673
+ return Object.prototype.hasOwnProperty.call(instance, key) && typeof instance[key] === "function";
530
674
  }
531
675
  });
676
+ let warnedReentry = false;
532
677
  instance.render = function() {
533
- if (isRendering) return;
678
+ if (instance.__micraDestroyed) return;
679
+ if (isRendering) {
680
+ if (!warnedReentry) {
681
+ warn("render() re-entry detected \u2014 mutation inside a directive expression is ignored. Move state writes to a method.");
682
+ warnedReentry = true;
683
+ }
684
+ return;
685
+ }
534
686
  isRendering = true;
535
687
  try {
536
688
  applyDirectives(root, exprState, rawState, instance);
@@ -544,8 +696,22 @@ function mount(selector, definition) {
544
696
  }
545
697
  };
546
698
  instance.destroy = function() {
547
- var _a2;
548
- (_a2 = instance.__micraSubs) == null ? void 0 : _a2.forEach((unsub) => unsub());
699
+ var _a2, _b;
700
+ if (instance.__micraDestroyed) return;
701
+ instance.__micraDestroyed = true;
702
+ (_a2 = instance.__micraListeners) == null ? void 0 : _a2.forEach(({ el, type, fn }) => el.removeEventListener(type, fn));
703
+ instance.__micraListeners = [];
704
+ const clearFlags = (el) => {
705
+ const m = el;
706
+ delete m.__micraEvents;
707
+ delete m.__micraAtBound;
708
+ delete m.__micraModel;
709
+ delete m.__micraCache;
710
+ };
711
+ clearFlags(root);
712
+ root.querySelectorAll("*").forEach(clearFlags);
713
+ (_b = instance.__micraSubs) == null ? void 0 : _b.forEach((unsub) => unsub());
714
+ instance.__micraSubs = [];
549
715
  if (typeof definition.onDestroy === "function")
550
716
  definition.onDestroy.call(instance);
551
717
  _instances.delete(root);