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/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 v2.0.0 — https://github.com/micra-js/micra — MIT */
2
2
 
3
3
  // src/utils/fetch.ts
4
4
  function getCSRF() {
@@ -31,9 +31,9 @@ async function micraFetch(url, options = {}) {
31
31
  }
32
32
  if (Object.keys(params).length)
33
33
  finalUrl += (url.includes("?") ? "&" : "?") + new URLSearchParams(params);
34
- } else {
34
+ } else if (options.body !== void 0) {
35
35
  headers["Content-Type"] = "application/json";
36
- body = JSON.stringify(options.body !== void 0 ? options.body : options);
36
+ body = JSON.stringify(options.body);
37
37
  }
38
38
  const res = await fetch(finalUrl, {
39
39
  method,
@@ -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;
@@ -159,7 +224,13 @@ function queryAll(root, sel) {
159
224
  return Array.from(root.querySelectorAll(sel));
160
225
  }
161
226
  function queryOwn(root, attr) {
162
- return queryAll(root, `[${attr}]`).filter((el) => {
227
+ return filterOwn(root, queryAll(root, `[${attr}]`));
228
+ }
229
+ function queryOwnAll(root, sel) {
230
+ return filterOwn(root, queryAll(root, sel));
231
+ }
232
+ function filterOwn(root, els) {
233
+ return els.filter((el) => {
163
234
  let node = el.parentElement;
164
235
  while (node && node !== root) {
165
236
  if (node.hasAttribute("data-component")) return false;
@@ -179,15 +250,25 @@ function applyHtml(el, expr, state) {
179
250
  var _a;
180
251
  el.innerHTML = String((_a = evalExpr(expr, state)) != null ? _a : "");
181
252
  }
182
- function applyIf(el, expr, state) {
253
+ function applyIf(binding, state) {
254
+ const el = binding.el;
255
+ const truthy = !!evalExpr(binding.expr, state);
256
+ if (truthy) {
257
+ const ph = binding.placeholder;
258
+ if (ph && ph.parentNode) ph.parentNode.replaceChild(el, ph);
259
+ } else {
260
+ const parent = el.parentNode;
261
+ if (parent) {
262
+ if (!binding.placeholder) binding.placeholder = document.createComment("if");
263
+ parent.replaceChild(binding.placeholder, el);
264
+ }
265
+ }
266
+ }
267
+ function applyShow(el, expr, state) {
183
268
  el.style.display = evalExpr(expr, state) ? "" : "none";
184
269
  }
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();
270
+ function applyBind(el, pairs, state) {
271
+ for (const [attr, valExpr] of pairs) {
191
272
  const val = evalExpr(valExpr, state);
192
273
  if (attr === "class") {
193
274
  el.className = String(val != null ? val : "");
@@ -207,21 +288,28 @@ function applyBind(el, expr, state) {
207
288
  }
208
289
  }
209
290
  }
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;
291
+ function applyClass(el, pairs, state) {
292
+ for (const [cls, valExpr] of pairs) {
217
293
  el.classList.toggle(cls, Boolean(evalExpr(valExpr, state)));
218
294
  }
219
295
  }
296
+ function parsePairs(expr) {
297
+ const out = [];
298
+ for (const part of expr.split(",")) {
299
+ const colonIdx = part.indexOf(":");
300
+ if (colonIdx === -1) continue;
301
+ const left = part.slice(0, colonIdx).trim();
302
+ const right = part.slice(colonIdx + 1).trim();
303
+ if (!left) continue;
304
+ out.push([left, right]);
305
+ }
306
+ return out;
307
+ }
220
308
  function applyModel(el, key, rawState) {
221
309
  const html = el;
222
- if (document.activeElement !== el) {
223
- html.value = rawState[key] == null ? "" : String(rawState[key]);
224
- }
310
+ const stateVal = rawState[key];
311
+ const desired = stateVal == null ? "" : String(stateVal);
312
+ if (html.value !== desired) html.value = desired;
225
313
  }
226
314
  function buildCache(root) {
227
315
  const pick = (attr) => {
@@ -230,14 +318,15 @@ function buildCache(root) {
230
318
  if ((_a = root.hasAttribute) == null ? void 0 : _a.call(root, attr)) els.unshift(root);
231
319
  return els.filter((el) => !el.closest("template")).map((el) => ({ el, expr: el.getAttribute(attr) }));
232
320
  };
321
+ const pickPairs = (attr) => pick(attr).map((b) => ({ ...b, pairs: parsePairs(b.expr) }));
233
322
  return {
234
323
  text: pick("data-text"),
235
324
  html: pick("data-html"),
236
325
  if: pick("data-if"),
237
326
  show: pick("data-show"),
238
- bind: pick("data-bind"),
327
+ bind: pickPairs("data-bind"),
239
328
  model: pick("data-model"),
240
- class: pick("data-class")
329
+ class: pickPairs("data-class")
241
330
  };
242
331
  }
243
332
  function applyDirectives(root, state, rawState, _instance) {
@@ -250,35 +339,56 @@ function applyDirectives(root, state, rawState, _instance) {
250
339
  applyFromList(el.__micraCache, state, rawState);
251
340
  }
252
341
  function applyFromList(cache, state, rawState) {
342
+ cache.if.forEach((b) => applyIf(b, state));
253
343
  cache.text.forEach((b) => applyText(b.el, b.expr, state));
254
344
  cache.html.forEach((b) => applyHtml(b.el, b.expr, state));
255
- cache.if.forEach((b) => applyIf(b.el, b.expr, state));
256
- cache.show.forEach((b) => applyIf(b.el, b.expr, state));
257
- cache.bind.forEach((b) => applyBind(b.el, b.expr, state));
345
+ cache.show.forEach((b) => applyShow(b.el, b.expr, state));
346
+ cache.bind.forEach((b) => applyBind(b.el, b.pairs, state));
258
347
  cache.model.forEach((b) => applyModel(b.el, b.expr.trim(), rawState));
259
- cache.class.forEach((b) => applyClass(b.el, b.expr, state));
348
+ cache.class.forEach((b) => applyClass(b.el, b.pairs, state));
260
349
  }
261
350
  function buildFragmentList(frag) {
262
351
  const pick = (attr) => queryAll(frag, `[${attr}]`).filter((el) => !el.closest("template")).map((el) => ({ el, expr: el.getAttribute(attr) }));
352
+ const pickPairs = (attr) => pick(attr).map((b) => ({ ...b, pairs: parsePairs(b.expr) }));
263
353
  return {
264
354
  text: pick("data-text"),
265
355
  html: pick("data-html"),
266
356
  if: pick("data-if"),
267
357
  show: pick("data-show"),
268
- bind: pick("data-bind"),
358
+ bind: pickPairs("data-bind"),
269
359
  model: pick("data-model"),
270
- class: pick("data-class")
360
+ class: pickPairs("data-class")
271
361
  };
272
362
  }
273
363
  function validateDirectives(root) {
364
+ var _a, _b;
274
365
  queryOwn(root, "data-each").forEach((el) => {
275
- if (!el.hasAttribute("data-key")) {
366
+ const tmpl = el;
367
+ if (!el.hasAttribute("data-key") && !tmpl.__micraNoKeyWarned) {
368
+ tmpl.__micraNoKeyWarned = true;
276
369
  warn(`data-each="${el.getAttribute("data-each")}" has no data-key \u2014 keyed diff disabled. Add data-key="id" for better performance.`);
277
370
  }
278
371
  });
372
+ const bindEls = queryOwn(root, "data-bind");
373
+ if (((_a = root.hasAttribute) == null ? void 0 : _a.call(root, "data-bind")) && !bindEls.includes(root)) bindEls.unshift(root);
374
+ for (const el of bindEls) {
375
+ const spec = (_b = el.getAttribute("data-bind")) != null ? _b : "";
376
+ const hasClassBind = spec.split(",").some((p) => {
377
+ var _a2;
378
+ return ((_a2 = p.trim().split(":")[0]) == null ? void 0 : _a2.trim()) === "class";
379
+ });
380
+ if (hasClassBind && el.hasAttribute("data-class")) {
381
+ warn(`element has both data-bind="class:..." and data-class \u2014 they fight on every render. Use one.`);
382
+ }
383
+ }
279
384
  }
280
385
 
281
386
  // src/dom/events.ts
387
+ function track(instance, el, type, fn) {
388
+ var _a;
389
+ el.addEventListener(type, fn);
390
+ ((_a = instance.__micraListeners) != null ? _a : instance.__micraListeners = []).push({ el, type, fn });
391
+ }
282
392
  function bindDataOn(root, instance) {
283
393
  var _a, _b;
284
394
  const isFragment = root.nodeType === 11;
@@ -294,7 +404,7 @@ function bindDataOn(root, instance) {
294
404
  const [evSpec, method] = part.trim().split(":");
295
405
  if (!evSpec || !method) continue;
296
406
  const [evName, ...mods] = evSpec.split(".");
297
- el.addEventListener(evName, (e) => {
407
+ track(instance, el, evName, (e) => {
298
408
  if (mods.includes("prevent")) e.preventDefault();
299
409
  if (mods.includes("stop")) e.stopPropagation();
300
410
  if (mods.includes("self") && e.target !== el) return;
@@ -306,16 +416,18 @@ function bindDataOn(root, instance) {
306
416
  }
307
417
  }
308
418
  function bindAtEvents(root, instance) {
309
- const mRoot = root;
310
- if (mRoot.__micraAtScanned) return;
311
- mRoot.__micraAtScanned = true;
312
- const all = queryAll(root, "*");
419
+ const isFragment = root.nodeType === 11;
420
+ const all = isFragment ? queryAll(root, "*") : queryOwnAll(root, "*");
421
+ if (!isFragment && !all.includes(root)) all.unshift(root);
313
422
  for (const el of all) {
423
+ const mEl = el;
424
+ if (mEl.__micraAtBound) continue;
425
+ let bound = false;
314
426
  for (const attr of Array.from(el.attributes)) {
315
427
  if (!attr.name.startsWith("@")) continue;
316
428
  const [evSpec, ...rest] = attr.name.slice(1).split(".");
317
429
  const method = attr.value.trim();
318
- el.addEventListener(evSpec, (e) => {
430
+ track(instance, el, evSpec, (e) => {
319
431
  if (rest.includes("prevent")) e.preventDefault();
320
432
  if (rest.includes("stop")) e.stopPropagation();
321
433
  if (rest.includes("self") && e.target !== el) return;
@@ -323,7 +435,9 @@ function bindAtEvents(root, instance) {
323
435
  if (typeof fn === "function") fn.call(instance, e);
324
436
  else warn(`method "${method}" not found`);
325
437
  });
438
+ bound = true;
326
439
  }
440
+ if (bound) mEl.__micraAtBound = true;
327
441
  }
328
442
  }
329
443
  function bindModels(root, instance) {
@@ -336,14 +450,22 @@ function bindModels(root, instance) {
336
450
  mEl.__micraModel = true;
337
451
  const key = (_a = el.dataset["model"]) != null ? _a : "";
338
452
  const tag = el.tagName;
453
+ const inputEl = el;
454
+ const inputType = inputEl.type;
339
455
  const update = () => {
340
- const val = tag === "INPUT" && el.type === "checkbox" ? el.checked : el.value;
456
+ let val;
457
+ if (tag === "INPUT" && inputType === "checkbox") {
458
+ val = inputEl.checked;
459
+ } else if (tag === "INPUT" && (inputType === "number" || inputType === "range")) {
460
+ val = inputEl.value === "" ? null : inputEl.valueAsNumber;
461
+ } else {
462
+ val = inputEl.value;
463
+ }
464
+ ;
341
465
  instance.state[key] = val;
342
466
  };
343
- el.addEventListener(
344
- tag === "SELECT" || el.type === "radio" ? "change" : "input",
345
- update
346
- );
467
+ const evType = tag === "SELECT" || inputType === "radio" ? "change" : "input";
468
+ track(instance, el, evType, update);
347
469
  }
348
470
  }
349
471
 
@@ -366,6 +488,7 @@ function renderList(root, state, rawState, instance) {
366
488
  const marker = tmpl.__micraMarker;
367
489
  const keyMap = tmpl.__micraNodes;
368
490
  const parent = marker.parentNode;
491
+ if (!parent) return;
369
492
  if (!Array.isArray(items)) {
370
493
  tmpl.__micraList.forEach((n) => n.remove());
371
494
  tmpl.__micraList = [];
@@ -382,9 +505,18 @@ function renderList(root, state, rawState, instance) {
382
505
  function renderKeyed(tmpl, items, keyAttr, marker, keyMap, parent, state, rawState, instance) {
383
506
  const nextKeys = /* @__PURE__ */ new Set();
384
507
  const nextNodes = [];
508
+ let warnedNullKey = false;
509
+ let warnedDupKey = false;
385
510
  for (const [index, item] of items.entries()) {
386
511
  const key = item[keyAttr];
387
- if (key == null) warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`);
512
+ if (key == null && !warnedNullKey) {
513
+ warn(`data-key="${keyAttr}" is null/undefined on item at index ${index}`);
514
+ warnedNullKey = true;
515
+ }
516
+ if (nextKeys.has(key) && !warnedDupKey) {
517
+ warn(`data-key="${keyAttr}" has duplicate value ${JSON.stringify(key)} \u2014 rows will collide`);
518
+ warnedDupKey = true;
519
+ }
388
520
  nextKeys.add(key);
389
521
  let node = keyMap.get(key);
390
522
  if (!node) {
@@ -487,15 +619,35 @@ function mount(selector, definition) {
487
619
  let isRendering = false;
488
620
  const schedule = createScheduler(() => instance.render());
489
621
  instance.state = createReactiveState(rawState, schedule);
622
+ const boundMethods = /* @__PURE__ */ new Map();
490
623
  const exprState = new Proxy(rawState, {
491
624
  get(target, key) {
492
- if (key in target) return target[key];
493
- if (key in instance) return instance[key];
625
+ if (Object.prototype.hasOwnProperty.call(target, key)) return target[key];
626
+ if (Object.prototype.hasOwnProperty.call(instance, key) && typeof instance[key] === "function") {
627
+ const cached = boundMethods.get(key);
628
+ if (cached) return cached;
629
+ const bound = instance[key].bind(instance);
630
+ boundMethods.set(key, bound);
631
+ return bound;
632
+ }
494
633
  return void 0;
634
+ },
635
+ has(target, key) {
636
+ if (typeof key !== "string") return false;
637
+ if (Object.prototype.hasOwnProperty.call(target, key)) return true;
638
+ return Object.prototype.hasOwnProperty.call(instance, key) && typeof instance[key] === "function";
495
639
  }
496
640
  });
641
+ let warnedReentry = false;
497
642
  instance.render = function() {
498
- if (isRendering) return;
643
+ if (instance.__micraDestroyed) return;
644
+ if (isRendering) {
645
+ if (!warnedReentry) {
646
+ warn("render() re-entry detected \u2014 mutation inside a directive expression is ignored. Move state writes to a method.");
647
+ warnedReentry = true;
648
+ }
649
+ return;
650
+ }
499
651
  isRendering = true;
500
652
  try {
501
653
  applyDirectives(root, exprState, rawState, instance);
@@ -509,8 +661,22 @@ function mount(selector, definition) {
509
661
  }
510
662
  };
511
663
  instance.destroy = function() {
512
- var _a2;
513
- (_a2 = instance.__micraSubs) == null ? void 0 : _a2.forEach((unsub) => unsub());
664
+ var _a2, _b;
665
+ if (instance.__micraDestroyed) return;
666
+ instance.__micraDestroyed = true;
667
+ (_a2 = instance.__micraListeners) == null ? void 0 : _a2.forEach(({ el, type, fn }) => el.removeEventListener(type, fn));
668
+ instance.__micraListeners = [];
669
+ const clearFlags = (el) => {
670
+ const m = el;
671
+ delete m.__micraEvents;
672
+ delete m.__micraAtBound;
673
+ delete m.__micraModel;
674
+ delete m.__micraCache;
675
+ };
676
+ clearFlags(root);
677
+ root.querySelectorAll("*").forEach(clearFlags);
678
+ (_b = instance.__micraSubs) == null ? void 0 : _b.forEach((unsub) => unsub());
679
+ instance.__micraSubs = [];
514
680
  if (typeof definition.onDestroy === "function")
515
681
  definition.onDestroy.call(instance);
516
682
  _instances.delete(root);