micra.js 1.0.0 → 1.1.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
@@ -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 },
@@ -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
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 v1.1.0 — https://github.com/micra-js/micra — MIT */
2
2
  "use strict";
3
3
  var __defProp = Object.defineProperty;
4
4
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -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;
@@ -217,12 +282,8 @@ function applyHtml(el, expr, state) {
217
282
  function applyIf(el, expr, state) {
218
283
  el.style.display = evalExpr(expr, state) ? "" : "none";
219
284
  }
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();
285
+ function applyBind(el, pairs, state) {
286
+ for (const [attr, valExpr] of pairs) {
226
287
  const val = evalExpr(valExpr, state);
227
288
  if (attr === "class") {
228
289
  el.className = String(val != null ? val : "");
@@ -242,21 +303,28 @@ function applyBind(el, expr, state) {
242
303
  }
243
304
  }
244
305
  }
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;
306
+ function applyClass(el, pairs, state) {
307
+ for (const [cls, valExpr] of pairs) {
252
308
  el.classList.toggle(cls, Boolean(evalExpr(valExpr, state)));
253
309
  }
254
310
  }
311
+ function parsePairs(expr) {
312
+ const out = [];
313
+ for (const part of expr.split(",")) {
314
+ const colonIdx = part.indexOf(":");
315
+ if (colonIdx === -1) continue;
316
+ const left = part.slice(0, colonIdx).trim();
317
+ const right = part.slice(colonIdx + 1).trim();
318
+ if (!left) continue;
319
+ out.push([left, right]);
320
+ }
321
+ return out;
322
+ }
255
323
  function applyModel(el, key, rawState) {
256
324
  const html = el;
257
- if (document.activeElement !== el) {
258
- html.value = rawState[key] == null ? "" : String(rawState[key]);
259
- }
325
+ const stateVal = rawState[key];
326
+ const desired = stateVal == null ? "" : String(stateVal);
327
+ if (html.value !== desired) html.value = desired;
260
328
  }
261
329
  function buildCache(root) {
262
330
  const pick = (attr) => {
@@ -265,14 +333,15 @@ function buildCache(root) {
265
333
  if ((_a = root.hasAttribute) == null ? void 0 : _a.call(root, attr)) els.unshift(root);
266
334
  return els.filter((el) => !el.closest("template")).map((el) => ({ el, expr: el.getAttribute(attr) }));
267
335
  };
336
+ const pickPairs = (attr) => pick(attr).map((b) => ({ ...b, pairs: parsePairs(b.expr) }));
268
337
  return {
269
338
  text: pick("data-text"),
270
339
  html: pick("data-html"),
271
340
  if: pick("data-if"),
272
341
  show: pick("data-show"),
273
- bind: pick("data-bind"),
342
+ bind: pickPairs("data-bind"),
274
343
  model: pick("data-model"),
275
- class: pick("data-class")
344
+ class: pickPairs("data-class")
276
345
  };
277
346
  }
278
347
  function applyDirectives(root, state, rawState, _instance) {
@@ -289,31 +358,52 @@ function applyFromList(cache, state, rawState) {
289
358
  cache.html.forEach((b) => applyHtml(b.el, b.expr, state));
290
359
  cache.if.forEach((b) => applyIf(b.el, b.expr, state));
291
360
  cache.show.forEach((b) => applyIf(b.el, b.expr, state));
292
- cache.bind.forEach((b) => applyBind(b.el, b.expr, state));
361
+ cache.bind.forEach((b) => applyBind(b.el, b.pairs, state));
293
362
  cache.model.forEach((b) => applyModel(b.el, b.expr.trim(), rawState));
294
- cache.class.forEach((b) => applyClass(b.el, b.expr, state));
363
+ cache.class.forEach((b) => applyClass(b.el, b.pairs, state));
295
364
  }
296
365
  function buildFragmentList(frag) {
297
366
  const pick = (attr) => queryAll(frag, `[${attr}]`).filter((el) => !el.closest("template")).map((el) => ({ el, expr: el.getAttribute(attr) }));
367
+ const pickPairs = (attr) => pick(attr).map((b) => ({ ...b, pairs: parsePairs(b.expr) }));
298
368
  return {
299
369
  text: pick("data-text"),
300
370
  html: pick("data-html"),
301
371
  if: pick("data-if"),
302
372
  show: pick("data-show"),
303
- bind: pick("data-bind"),
373
+ bind: pickPairs("data-bind"),
304
374
  model: pick("data-model"),
305
- class: pick("data-class")
375
+ class: pickPairs("data-class")
306
376
  };
307
377
  }
308
378
  function validateDirectives(root) {
379
+ var _a, _b;
309
380
  queryOwn(root, "data-each").forEach((el) => {
310
- if (!el.hasAttribute("data-key")) {
381
+ const tmpl = el;
382
+ if (!el.hasAttribute("data-key") && !tmpl.__micraNoKeyWarned) {
383
+ tmpl.__micraNoKeyWarned = true;
311
384
  warn(`data-each="${el.getAttribute("data-each")}" has no data-key \u2014 keyed diff disabled. Add data-key="id" for better performance.`);
312
385
  }
313
386
  });
387
+ const bindEls = queryOwn(root, "data-bind");
388
+ if (((_a = root.hasAttribute) == null ? void 0 : _a.call(root, "data-bind")) && !bindEls.includes(root)) bindEls.unshift(root);
389
+ for (const el of bindEls) {
390
+ const spec = (_b = el.getAttribute("data-bind")) != null ? _b : "";
391
+ const hasClassBind = spec.split(",").some((p) => {
392
+ var _a2;
393
+ return ((_a2 = p.trim().split(":")[0]) == null ? void 0 : _a2.trim()) === "class";
394
+ });
395
+ if (hasClassBind && el.hasAttribute("data-class")) {
396
+ warn(`element has both data-bind="class:..." and data-class \u2014 they fight on every render. Use one.`);
397
+ }
398
+ }
314
399
  }
315
400
 
316
401
  // src/dom/events.ts
402
+ function track(instance, el, type, fn) {
403
+ var _a;
404
+ el.addEventListener(type, fn);
405
+ ((_a = instance.__micraListeners) != null ? _a : instance.__micraListeners = []).push({ el, type, fn });
406
+ }
317
407
  function bindDataOn(root, instance) {
318
408
  var _a, _b;
319
409
  const isFragment = root.nodeType === 11;
@@ -329,7 +419,7 @@ function bindDataOn(root, instance) {
329
419
  const [evSpec, method] = part.trim().split(":");
330
420
  if (!evSpec || !method) continue;
331
421
  const [evName, ...mods] = evSpec.split(".");
332
- el.addEventListener(evName, (e) => {
422
+ track(instance, el, evName, (e) => {
333
423
  if (mods.includes("prevent")) e.preventDefault();
334
424
  if (mods.includes("stop")) e.stopPropagation();
335
425
  if (mods.includes("self") && e.target !== el) return;
@@ -341,16 +431,18 @@ function bindDataOn(root, instance) {
341
431
  }
342
432
  }
343
433
  function bindAtEvents(root, instance) {
344
- const mRoot = root;
345
- if (mRoot.__micraAtScanned) return;
346
- mRoot.__micraAtScanned = true;
347
- const all = queryAll(root, "*");
434
+ const isFragment = root.nodeType === 11;
435
+ const all = isFragment ? queryAll(root, "*") : queryAll(root, "*");
436
+ if (!isFragment && !all.includes(root)) all.unshift(root);
348
437
  for (const el of all) {
438
+ const mEl = el;
439
+ if (mEl.__micraAtBound) continue;
440
+ let bound = false;
349
441
  for (const attr of Array.from(el.attributes)) {
350
442
  if (!attr.name.startsWith("@")) continue;
351
443
  const [evSpec, ...rest] = attr.name.slice(1).split(".");
352
444
  const method = attr.value.trim();
353
- el.addEventListener(evSpec, (e) => {
445
+ track(instance, el, evSpec, (e) => {
354
446
  if (rest.includes("prevent")) e.preventDefault();
355
447
  if (rest.includes("stop")) e.stopPropagation();
356
448
  if (rest.includes("self") && e.target !== el) return;
@@ -358,7 +450,9 @@ function bindAtEvents(root, instance) {
358
450
  if (typeof fn === "function") fn.call(instance, e);
359
451
  else warn(`method "${method}" not found`);
360
452
  });
453
+ bound = true;
361
454
  }
455
+ if (bound) mEl.__micraAtBound = true;
362
456
  }
363
457
  }
364
458
  function bindModels(root, instance) {
@@ -371,14 +465,22 @@ function bindModels(root, instance) {
371
465
  mEl.__micraModel = true;
372
466
  const key = (_a = el.dataset["model"]) != null ? _a : "";
373
467
  const tag = el.tagName;
468
+ const inputEl = el;
469
+ const inputType = inputEl.type;
374
470
  const update = () => {
375
- const val = tag === "INPUT" && el.type === "checkbox" ? el.checked : el.value;
471
+ let val;
472
+ if (tag === "INPUT" && inputType === "checkbox") {
473
+ val = inputEl.checked;
474
+ } else if (tag === "INPUT" && (inputType === "number" || inputType === "range")) {
475
+ val = inputEl.value === "" ? null : inputEl.valueAsNumber;
476
+ } else {
477
+ val = inputEl.value;
478
+ }
479
+ ;
376
480
  instance.state[key] = val;
377
481
  };
378
- el.addEventListener(
379
- tag === "SELECT" || el.type === "radio" ? "change" : "input",
380
- update
381
- );
482
+ const evType = tag === "SELECT" || inputType === "radio" ? "change" : "input";
483
+ track(instance, el, evType, update);
382
484
  }
383
485
  }
384
486
 
@@ -417,9 +519,18 @@ function renderList(root, state, rawState, instance) {
417
519
  function renderKeyed(tmpl, items, keyAttr, marker, keyMap, parent, state, rawState, instance) {
418
520
  const nextKeys = /* @__PURE__ */ new Set();
419
521
  const nextNodes = [];
522
+ let warnedNullKey = false;
523
+ let warnedDupKey = false;
420
524
  for (const [index, item] of items.entries()) {
421
525
  const key = item[keyAttr];
422
- if (key == null) warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`);
526
+ if (key == null && !warnedNullKey) {
527
+ warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`);
528
+ warnedNullKey = true;
529
+ }
530
+ if (nextKeys.has(key) && !warnedDupKey) {
531
+ warn(`data-key="${keyAttr}" has duplicate value ${JSON.stringify(key)} \u2014 rows will collide`);
532
+ warnedDupKey = true;
533
+ }
423
534
  nextKeys.add(key);
424
535
  let node = keyMap.get(key);
425
536
  if (!node) {
@@ -522,15 +633,35 @@ function mount(selector, definition) {
522
633
  let isRendering = false;
523
634
  const schedule = createScheduler(() => instance.render());
524
635
  instance.state = createReactiveState(rawState, schedule);
636
+ const boundMethods = /* @__PURE__ */ new Map();
525
637
  const exprState = new Proxy(rawState, {
526
638
  get(target, key) {
527
- if (key in target) return target[key];
528
- if (key in instance) return instance[key];
639
+ if (Object.prototype.hasOwnProperty.call(target, key)) return target[key];
640
+ if (Object.prototype.hasOwnProperty.call(instance, key) && typeof instance[key] === "function") {
641
+ const cached = boundMethods.get(key);
642
+ if (cached) return cached;
643
+ const bound = instance[key].bind(instance);
644
+ boundMethods.set(key, bound);
645
+ return bound;
646
+ }
529
647
  return void 0;
648
+ },
649
+ has(target, key) {
650
+ if (typeof key !== "string") return false;
651
+ if (Object.prototype.hasOwnProperty.call(target, key)) return true;
652
+ return Object.prototype.hasOwnProperty.call(instance, key) && typeof instance[key] === "function";
530
653
  }
531
654
  });
655
+ let warnedReentry = false;
532
656
  instance.render = function() {
533
- if (isRendering) return;
657
+ if (instance.__micraDestroyed) return;
658
+ if (isRendering) {
659
+ if (!warnedReentry) {
660
+ warn("render() re-entry detected \u2014 mutation inside a directive expression is ignored. Move state writes to a method.");
661
+ warnedReentry = true;
662
+ }
663
+ return;
664
+ }
534
665
  isRendering = true;
535
666
  try {
536
667
  applyDirectives(root, exprState, rawState, instance);
@@ -544,8 +675,22 @@ function mount(selector, definition) {
544
675
  }
545
676
  };
546
677
  instance.destroy = function() {
547
- var _a2;
548
- (_a2 = instance.__micraSubs) == null ? void 0 : _a2.forEach((unsub) => unsub());
678
+ var _a2, _b;
679
+ if (instance.__micraDestroyed) return;
680
+ instance.__micraDestroyed = true;
681
+ (_a2 = instance.__micraListeners) == null ? void 0 : _a2.forEach(({ el, type, fn }) => el.removeEventListener(type, fn));
682
+ instance.__micraListeners = [];
683
+ const clearFlags = (el) => {
684
+ const m = el;
685
+ delete m.__micraEvents;
686
+ delete m.__micraAtBound;
687
+ delete m.__micraModel;
688
+ delete m.__micraCache;
689
+ };
690
+ clearFlags(root);
691
+ root.querySelectorAll("*").forEach(clearFlags);
692
+ (_b = instance.__micraSubs) == null ? void 0 : _b.forEach((unsub) => unsub());
693
+ instance.__micraSubs = [];
549
694
  if (typeof definition.onDestroy === "function")
550
695
  definition.onDestroy.call(instance);
551
696
  _instances.delete(root);