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/dist/micra.esm.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
 
3
3
  // src/utils/fetch.ts
4
4
  function getCSRF() {
@@ -80,10 +80,69 @@ function debug() {
80
80
 
81
81
  // src/utils/expr.ts
82
82
  var exprCache = /* @__PURE__ */ new Map();
83
+ var warnedRuntime = /* @__PURE__ */ new Set();
83
84
  var SIMPLE_PATH = /^[a-zA-Z_$][a-zA-Z0-9_$]*(\.[a-zA-Z_$][a-zA-Z0-9_$]*)*$/;
85
+ var ALLOWED_GLOBALS = /* @__PURE__ */ new Set([
86
+ "Math",
87
+ "JSON",
88
+ "Date",
89
+ "String",
90
+ "Number",
91
+ "Boolean",
92
+ "Array",
93
+ "Object",
94
+ "parseInt",
95
+ "parseFloat",
96
+ "isNaN",
97
+ "isFinite",
98
+ "NaN",
99
+ "Infinity",
100
+ "undefined"
101
+ ]);
102
+ var PARAM_S = "$s";
103
+ var PARAM_SAFE = "$safe";
104
+ var SAFE_OUTER = new Proxy(/* @__PURE__ */ Object.create(null), {
105
+ has(_target, key) {
106
+ if (typeof key !== "string") return false;
107
+ if (key === PARAM_S || key === PARAM_SAFE) return false;
108
+ return !ALLOWED_GLOBALS.has(key);
109
+ },
110
+ get() {
111
+ return void 0;
112
+ }
113
+ });
114
+ var safeWrapCache = /* @__PURE__ */ new WeakMap();
115
+ var OBJ_PROTO_KEYS = new Set(Object.getOwnPropertyNames(Object.prototype));
116
+ function safeStateWrap(state) {
117
+ const cached = safeWrapCache.get(state);
118
+ if (cached) return cached;
119
+ const wrapped = new Proxy(state, {
120
+ has(target, key) {
121
+ return safeStateHas(target, key);
122
+ },
123
+ get(target, key) {
124
+ return Reflect.get(target, key);
125
+ }
126
+ });
127
+ safeWrapCache.set(state, wrapped);
128
+ return wrapped;
129
+ }
130
+ function safeStateHas(state, key) {
131
+ if (typeof key !== "string") return false;
132
+ if (!Reflect.has(state, key)) return false;
133
+ if (!OBJ_PROTO_KEYS.has(key)) return true;
134
+ let obj = state;
135
+ while (obj && obj !== Object.prototype) {
136
+ if (Object.prototype.hasOwnProperty.call(obj, key)) return true;
137
+ obj = Object.getPrototypeOf(obj);
138
+ }
139
+ return false;
140
+ }
84
141
  function evalExpr(expr, state) {
85
142
  if (SIMPLE_PATH.test(expr)) {
86
- return expr.split(".").reduce(
143
+ const parts = expr.split(".");
144
+ if (!safeStateHas(state, parts[0])) return void 0;
145
+ return parts.reduce(
87
146
  (obj, key) => obj != null ? obj[key] : void 0,
88
147
  state
89
148
  );
@@ -92,7 +151,7 @@ function evalExpr(expr, state) {
92
151
  try {
93
152
  exprCache.set(
94
153
  expr,
95
- new Function("$s", `with($s){return (${expr})}`)
154
+ new Function("$s", "$safe", `with($safe){with($s){return (${expr})}}`)
96
155
  );
97
156
  } catch {
98
157
  warn(`invalid expression "${expr}"`);
@@ -100,8 +159,12 @@ function evalExpr(expr, state) {
100
159
  }
101
160
  }
102
161
  try {
103
- return exprCache.get(expr)(state);
104
- } catch {
162
+ return exprCache.get(expr)(safeStateWrap(state), SAFE_OUTER);
163
+ } catch (e) {
164
+ if (!warnedRuntime.has(expr)) {
165
+ warnedRuntime.add(expr);
166
+ warn(`runtime error in "${expr}": ${e.message}`);
167
+ }
105
168
  return void 0;
106
169
  }
107
170
  }
@@ -117,8 +180,10 @@ function on(event, handler) {
117
180
  return () => off(event, handler);
118
181
  }
119
182
  function off(event, handler) {
120
- var _a;
121
- (_a = _bus.get(event)) == null ? void 0 : _a.delete(handler);
183
+ const set = _bus.get(event);
184
+ if (!set) return;
185
+ set.delete(handler);
186
+ if (set.size === 0) _bus.delete(event);
122
187
  }
123
188
  function emit(event, payload) {
124
189
  var _a;
@@ -182,12 +247,8 @@ function applyHtml(el, expr, state) {
182
247
  function applyIf(el, expr, state) {
183
248
  el.style.display = evalExpr(expr, state) ? "" : "none";
184
249
  }
185
- function applyBind(el, expr, state) {
186
- for (const pair of expr.split(",")) {
187
- const colonIdx = pair.indexOf(":");
188
- if (colonIdx === -1) continue;
189
- const attr = pair.slice(0, colonIdx).trim();
190
- const valExpr = pair.slice(colonIdx + 1).trim();
250
+ function applyBind(el, pairs, state) {
251
+ for (const [attr, valExpr] of pairs) {
191
252
  const val = evalExpr(valExpr, state);
192
253
  if (attr === "class") {
193
254
  el.className = String(val != null ? val : "");
@@ -207,21 +268,28 @@ function applyBind(el, expr, state) {
207
268
  }
208
269
  }
209
270
  }
210
- function applyClass(el, expr, state) {
211
- for (const pair of expr.split(",")) {
212
- const colonIdx = pair.indexOf(":");
213
- if (colonIdx === -1) continue;
214
- const cls = pair.slice(0, colonIdx).trim();
215
- const valExpr = pair.slice(colonIdx + 1).trim();
216
- if (!cls) continue;
271
+ function applyClass(el, pairs, state) {
272
+ for (const [cls, valExpr] of pairs) {
217
273
  el.classList.toggle(cls, Boolean(evalExpr(valExpr, state)));
218
274
  }
219
275
  }
276
+ function parsePairs(expr) {
277
+ const out = [];
278
+ for (const part of expr.split(",")) {
279
+ const colonIdx = part.indexOf(":");
280
+ if (colonIdx === -1) continue;
281
+ const left = part.slice(0, colonIdx).trim();
282
+ const right = part.slice(colonIdx + 1).trim();
283
+ if (!left) continue;
284
+ out.push([left, right]);
285
+ }
286
+ return out;
287
+ }
220
288
  function applyModel(el, key, rawState) {
221
289
  const html = el;
222
- if (document.activeElement !== el) {
223
- html.value = rawState[key] == null ? "" : String(rawState[key]);
224
- }
290
+ const stateVal = rawState[key];
291
+ const desired = stateVal == null ? "" : String(stateVal);
292
+ if (html.value !== desired) html.value = desired;
225
293
  }
226
294
  function buildCache(root) {
227
295
  const pick = (attr) => {
@@ -230,14 +298,15 @@ function buildCache(root) {
230
298
  if ((_a = root.hasAttribute) == null ? void 0 : _a.call(root, attr)) els.unshift(root);
231
299
  return els.filter((el) => !el.closest("template")).map((el) => ({ el, expr: el.getAttribute(attr) }));
232
300
  };
301
+ const pickPairs = (attr) => pick(attr).map((b) => ({ ...b, pairs: parsePairs(b.expr) }));
233
302
  return {
234
303
  text: pick("data-text"),
235
304
  html: pick("data-html"),
236
305
  if: pick("data-if"),
237
306
  show: pick("data-show"),
238
- bind: pick("data-bind"),
307
+ bind: pickPairs("data-bind"),
239
308
  model: pick("data-model"),
240
- class: pick("data-class")
309
+ class: pickPairs("data-class")
241
310
  };
242
311
  }
243
312
  function applyDirectives(root, state, rawState, _instance) {
@@ -254,31 +323,52 @@ function applyFromList(cache, state, rawState) {
254
323
  cache.html.forEach((b) => applyHtml(b.el, b.expr, state));
255
324
  cache.if.forEach((b) => applyIf(b.el, b.expr, state));
256
325
  cache.show.forEach((b) => applyIf(b.el, b.expr, state));
257
- cache.bind.forEach((b) => applyBind(b.el, b.expr, state));
326
+ cache.bind.forEach((b) => applyBind(b.el, b.pairs, state));
258
327
  cache.model.forEach((b) => applyModel(b.el, b.expr.trim(), rawState));
259
- cache.class.forEach((b) => applyClass(b.el, b.expr, state));
328
+ cache.class.forEach((b) => applyClass(b.el, b.pairs, state));
260
329
  }
261
330
  function buildFragmentList(frag) {
262
331
  const pick = (attr) => queryAll(frag, `[${attr}]`).filter((el) => !el.closest("template")).map((el) => ({ el, expr: el.getAttribute(attr) }));
332
+ const pickPairs = (attr) => pick(attr).map((b) => ({ ...b, pairs: parsePairs(b.expr) }));
263
333
  return {
264
334
  text: pick("data-text"),
265
335
  html: pick("data-html"),
266
336
  if: pick("data-if"),
267
337
  show: pick("data-show"),
268
- bind: pick("data-bind"),
338
+ bind: pickPairs("data-bind"),
269
339
  model: pick("data-model"),
270
- class: pick("data-class")
340
+ class: pickPairs("data-class")
271
341
  };
272
342
  }
273
343
  function validateDirectives(root) {
344
+ var _a, _b;
274
345
  queryOwn(root, "data-each").forEach((el) => {
275
- if (!el.hasAttribute("data-key")) {
346
+ const tmpl = el;
347
+ if (!el.hasAttribute("data-key") && !tmpl.__micraNoKeyWarned) {
348
+ tmpl.__micraNoKeyWarned = true;
276
349
  warn(`data-each="${el.getAttribute("data-each")}" has no data-key \u2014 keyed diff disabled. Add data-key="id" for better performance.`);
277
350
  }
278
351
  });
352
+ const bindEls = queryOwn(root, "data-bind");
353
+ if (((_a = root.hasAttribute) == null ? void 0 : _a.call(root, "data-bind")) && !bindEls.includes(root)) bindEls.unshift(root);
354
+ for (const el of bindEls) {
355
+ const spec = (_b = el.getAttribute("data-bind")) != null ? _b : "";
356
+ const hasClassBind = spec.split(",").some((p) => {
357
+ var _a2;
358
+ return ((_a2 = p.trim().split(":")[0]) == null ? void 0 : _a2.trim()) === "class";
359
+ });
360
+ if (hasClassBind && el.hasAttribute("data-class")) {
361
+ warn(`element has both data-bind="class:..." and data-class \u2014 they fight on every render. Use one.`);
362
+ }
363
+ }
279
364
  }
280
365
 
281
366
  // src/dom/events.ts
367
+ function track(instance, el, type, fn) {
368
+ var _a;
369
+ el.addEventListener(type, fn);
370
+ ((_a = instance.__micraListeners) != null ? _a : instance.__micraListeners = []).push({ el, type, fn });
371
+ }
282
372
  function bindDataOn(root, instance) {
283
373
  var _a, _b;
284
374
  const isFragment = root.nodeType === 11;
@@ -294,7 +384,7 @@ function bindDataOn(root, instance) {
294
384
  const [evSpec, method] = part.trim().split(":");
295
385
  if (!evSpec || !method) continue;
296
386
  const [evName, ...mods] = evSpec.split(".");
297
- el.addEventListener(evName, (e) => {
387
+ track(instance, el, evName, (e) => {
298
388
  if (mods.includes("prevent")) e.preventDefault();
299
389
  if (mods.includes("stop")) e.stopPropagation();
300
390
  if (mods.includes("self") && e.target !== el) return;
@@ -306,16 +396,18 @@ function bindDataOn(root, instance) {
306
396
  }
307
397
  }
308
398
  function bindAtEvents(root, instance) {
309
- const mRoot = root;
310
- if (mRoot.__micraAtScanned) return;
311
- mRoot.__micraAtScanned = true;
312
- const all = queryAll(root, "*");
399
+ const isFragment = root.nodeType === 11;
400
+ const all = isFragment ? queryAll(root, "*") : queryAll(root, "*");
401
+ if (!isFragment && !all.includes(root)) all.unshift(root);
313
402
  for (const el of all) {
403
+ const mEl = el;
404
+ if (mEl.__micraAtBound) continue;
405
+ let bound = false;
314
406
  for (const attr of Array.from(el.attributes)) {
315
407
  if (!attr.name.startsWith("@")) continue;
316
408
  const [evSpec, ...rest] = attr.name.slice(1).split(".");
317
409
  const method = attr.value.trim();
318
- el.addEventListener(evSpec, (e) => {
410
+ track(instance, el, evSpec, (e) => {
319
411
  if (rest.includes("prevent")) e.preventDefault();
320
412
  if (rest.includes("stop")) e.stopPropagation();
321
413
  if (rest.includes("self") && e.target !== el) return;
@@ -323,7 +415,9 @@ function bindAtEvents(root, instance) {
323
415
  if (typeof fn === "function") fn.call(instance, e);
324
416
  else warn(`method "${method}" not found`);
325
417
  });
418
+ bound = true;
326
419
  }
420
+ if (bound) mEl.__micraAtBound = true;
327
421
  }
328
422
  }
329
423
  function bindModels(root, instance) {
@@ -336,14 +430,22 @@ function bindModels(root, instance) {
336
430
  mEl.__micraModel = true;
337
431
  const key = (_a = el.dataset["model"]) != null ? _a : "";
338
432
  const tag = el.tagName;
433
+ const inputEl = el;
434
+ const inputType = inputEl.type;
339
435
  const update = () => {
340
- const val = tag === "INPUT" && el.type === "checkbox" ? el.checked : el.value;
436
+ let val;
437
+ if (tag === "INPUT" && inputType === "checkbox") {
438
+ val = inputEl.checked;
439
+ } else if (tag === "INPUT" && (inputType === "number" || inputType === "range")) {
440
+ val = inputEl.value === "" ? null : inputEl.valueAsNumber;
441
+ } else {
442
+ val = inputEl.value;
443
+ }
444
+ ;
341
445
  instance.state[key] = val;
342
446
  };
343
- el.addEventListener(
344
- tag === "SELECT" || el.type === "radio" ? "change" : "input",
345
- update
346
- );
447
+ const evType = tag === "SELECT" || inputType === "radio" ? "change" : "input";
448
+ track(instance, el, evType, update);
347
449
  }
348
450
  }
349
451
 
@@ -382,9 +484,18 @@ function renderList(root, state, rawState, instance) {
382
484
  function renderKeyed(tmpl, items, keyAttr, marker, keyMap, parent, state, rawState, instance) {
383
485
  const nextKeys = /* @__PURE__ */ new Set();
384
486
  const nextNodes = [];
487
+ let warnedNullKey = false;
488
+ let warnedDupKey = false;
385
489
  for (const [index, item] of items.entries()) {
386
490
  const key = item[keyAttr];
387
- if (key == null) warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`);
491
+ if (key == null && !warnedNullKey) {
492
+ warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`);
493
+ warnedNullKey = true;
494
+ }
495
+ if (nextKeys.has(key) && !warnedDupKey) {
496
+ warn(`data-key="${keyAttr}" has duplicate value ${JSON.stringify(key)} \u2014 rows will collide`);
497
+ warnedDupKey = true;
498
+ }
388
499
  nextKeys.add(key);
389
500
  let node = keyMap.get(key);
390
501
  if (!node) {
@@ -487,15 +598,35 @@ function mount(selector, definition) {
487
598
  let isRendering = false;
488
599
  const schedule = createScheduler(() => instance.render());
489
600
  instance.state = createReactiveState(rawState, schedule);
601
+ const boundMethods = /* @__PURE__ */ new Map();
490
602
  const exprState = new Proxy(rawState, {
491
603
  get(target, key) {
492
- if (key in target) return target[key];
493
- if (key in instance) return instance[key];
604
+ if (Object.prototype.hasOwnProperty.call(target, key)) return target[key];
605
+ if (Object.prototype.hasOwnProperty.call(instance, key) && typeof instance[key] === "function") {
606
+ const cached = boundMethods.get(key);
607
+ if (cached) return cached;
608
+ const bound = instance[key].bind(instance);
609
+ boundMethods.set(key, bound);
610
+ return bound;
611
+ }
494
612
  return void 0;
613
+ },
614
+ has(target, key) {
615
+ if (typeof key !== "string") return false;
616
+ if (Object.prototype.hasOwnProperty.call(target, key)) return true;
617
+ return Object.prototype.hasOwnProperty.call(instance, key) && typeof instance[key] === "function";
495
618
  }
496
619
  });
620
+ let warnedReentry = false;
497
621
  instance.render = function() {
498
- if (isRendering) return;
622
+ if (instance.__micraDestroyed) return;
623
+ if (isRendering) {
624
+ if (!warnedReentry) {
625
+ warn("render() re-entry detected \u2014 mutation inside a directive expression is ignored. Move state writes to a method.");
626
+ warnedReentry = true;
627
+ }
628
+ return;
629
+ }
499
630
  isRendering = true;
500
631
  try {
501
632
  applyDirectives(root, exprState, rawState, instance);
@@ -509,8 +640,22 @@ function mount(selector, definition) {
509
640
  }
510
641
  };
511
642
  instance.destroy = function() {
512
- var _a2;
513
- (_a2 = instance.__micraSubs) == null ? void 0 : _a2.forEach((unsub) => unsub());
643
+ var _a2, _b;
644
+ if (instance.__micraDestroyed) return;
645
+ instance.__micraDestroyed = true;
646
+ (_a2 = instance.__micraListeners) == null ? void 0 : _a2.forEach(({ el, type, fn }) => el.removeEventListener(type, fn));
647
+ instance.__micraListeners = [];
648
+ const clearFlags = (el) => {
649
+ const m = el;
650
+ delete m.__micraEvents;
651
+ delete m.__micraAtBound;
652
+ delete m.__micraModel;
653
+ delete m.__micraCache;
654
+ };
655
+ clearFlags(root);
656
+ root.querySelectorAll("*").forEach(clearFlags);
657
+ (_b = instance.__micraSubs) == null ? void 0 : _b.forEach((unsub) => unsub());
658
+ instance.__micraSubs = [];
514
659
  if (typeof definition.onDestroy === "function")
515
660
  definition.onDestroy.call(instance);
516
661
  _instances.delete(root);