laghu 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/src/laghu.js CHANGED
@@ -23,8 +23,10 @@
23
23
  */
24
24
  (function () {
25
25
  'use strict';
26
- var VERSION = '1.0.0';
27
- var DEV = typeof window !== 'undefined' && /** @type {any} */ (window).__LAGHU_DEV__ !== false;
26
+ var VERSION = '1.1.0';
27
+ // Opt-in dev noise. Production stays silent unless handlers listen
28
+ // for laghu:error / laghu:request-error, which always dispatch.
29
+ var DEV = typeof window !== 'undefined' && /** @type {any} */ (window).__LAGHU_DEV__ === true;
28
30
 
29
31
  /** @type {Map<string, any>} */
30
32
  var stores = new Map();
@@ -32,20 +34,78 @@
32
34
  var directives = new Map();
33
35
  /** @type {WeakMap<object, Map<string, Set<Function>>>} */
34
36
  var targetMap = new WeakMap();
37
+ /** Proxies created by reactive, so re-wrapping returns the same proxy
38
+ * instead of stacking proxy-on-proxy traps. */
39
+ /** @type {WeakMap<object, any>} */
40
+ var proxyCache = new WeakMap();
41
+ /** @type {WeakSet<object>} */
42
+ var proxySet = new WeakSet();
35
43
  /** @type {Function | null} */
36
44
  var activeEffect = null;
37
45
  /** @type {Set<Function>} */
38
46
  var jobQueue = new Set();
39
47
  var flushing = false;
48
+ /** Attributes whose values can navigate or load code: scriptable
49
+ * URLs are refused here (baseline; DOMPurify for untrusted input). */
50
+ /** @type {Record<string, number>} */
51
+ var URL_ATTRS = { href: 1, src: 1, action: 1, formaction: 1, cite: 1, poster: 1, data: 1, srcset: 1, srcdoc: 1 };
52
+ /** Path segments that must never be read or written: prototype
53
+ * pollution sinks and callable gadgets (constructor, toString).
54
+ * @param {string} seg */
55
+ function badSeg(seg) {
56
+ return seg === '__proto__' || seg === 'constructor' || seg === 'prototype';
57
+ }
58
+ /** True for URL values that execute code (checked case-insensitively,
59
+ * whitespace-insensitively, so casing tricks do not slip through).
60
+ * @param {any} v */
61
+ function unsafeUrl(v) {
62
+ var s = String(v).replace(/[\s\x00-\x1f]+/g, '').toLowerCase();
63
+ return s.indexOf('javascript:') === 0 || s.indexOf('data:text/html') === 0 || s.indexOf('vbscript:') === 0;
64
+ }
65
+ /**
66
+ * Baseline HTML cleaner used when no sanitizer is installed. Strips
67
+ * scripts, frames, event-handler attributes, and scriptable URLs.
68
+ * This is a floor, not a policy: pair with DOMPurify through
69
+ * Laghu.sanitize for untrusted content.
70
+ * @param {any} html
71
+ * @returns {string}
72
+ */
73
+ function defaultSanitize(html) {
74
+ var s = String(html);
75
+ s = s.replace(/<script[\s\S]*?(<\/script\s*>|$)/gi, '');
76
+ s = s.replace(/<\/?(iframe|object|embed|link|meta|base)[^>]*>/gi, '');
77
+ s = s.replace(/\son[a-z]+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '');
78
+ s = s.replace(/(href|src|xlink:href|action|formaction|poster|data)\s*=\s*("([^"]*)"|'([^']*)'|([^\s>]+))/gi, function (m, a, q, d1, d2, d3) {
79
+ var v = d1 !== undefined ? d1 : (d2 !== undefined ? d2 : d3);
80
+ if (unsafeUrl(v)) return a + '="#"';
81
+ return m;
82
+ });
83
+ return s;
84
+ }
40
85
  /** @type {(html: string) => string} */
41
- var sanitize = function (h) { return h; };
86
+ var sanitize = defaultSanitize;
42
87
 
88
+ /**
89
+ * Report a runtime failure. Always dispatches laghu:error on document
90
+ * (so production has signal without console noise); logs only in dev.
91
+ * @param {any} err
92
+ */
93
+ function reportError(err) {
94
+ try {
95
+ if (typeof document !== 'undefined' && document.dispatchEvent) {
96
+ document.dispatchEvent(new CustomEvent('laghu:error', {
97
+ detail: { error: String((err && err.message) || err), version: VERSION }
98
+ }));
99
+ }
100
+ } catch (e) {}
101
+ if (DEV && typeof console !== 'undefined') console.error('[laghu]', err);
102
+ }
43
103
  function flush() {
44
104
  flushing = false;
45
105
  var jobs = Array.from(jobQueue);
46
106
  jobQueue.clear();
47
107
  for (var i = 0; i < jobs.length; i++) {
48
- try { jobs[i](); } catch (e) { if (DEV && typeof console !== 'undefined') console.error('[laghu]', e); }
108
+ try { jobs[i](); } catch (e) { reportError(e); }
49
109
  }
50
110
  }
51
111
  /** @param {Function} fn */
@@ -53,13 +113,18 @@
53
113
  jobQueue.add(fn);
54
114
  if (!flushing) { flushing = true; Promise.resolve().then(flush); }
55
115
  }
116
+ /** Nesting stack so inner effects never clobber outer dependencies. @type {Array<Function>} */
117
+ var effectStack = [];
118
+ /** @type {WeakMap<Function, Array<Set<Function>>>} */
119
+ var runDeps = new WeakMap();
56
120
  /**
57
121
  * Run a function now and re-run it whenever reactive state it read
58
122
  * changes. Each DOM binding creates one effect, so updates touch only
59
123
  * the exact node that depends on the changed value.
60
124
  *
61
125
  * Effects are batched in a microtask: many synchronous writes cause
62
- * one re-run.
126
+ * one re-run. A throwing run reports laghu:error and keeps the page
127
+ * alive; subscriptions refresh per run so untaken branches release.
63
128
  *
64
129
  * @example
65
130
  * var state = Laghu.reactive({ count: 0 });
@@ -74,13 +139,30 @@
74
139
  */
75
140
  function effect(fn) {
76
141
  var stopped = false;
142
+ /** @type {Array<Set<Function>>} */
143
+ var deps = [];
77
144
  var run = function () {
78
145
  if (stopped) return;
146
+ // Drop last run's subscriptions first: conditional branches
147
+ // (a ? b : c) must not keep firing on the untaken path.
148
+ for (var i = 0; i < deps.length; i++) deps[i].delete(run);
149
+ deps.length = 0;
150
+ effectStack.push(run);
79
151
  activeEffect = run;
80
- try { fn(); } finally { activeEffect = null; }
152
+ runDeps.set(run, deps);
153
+ try { fn(); }
154
+ catch (e) { reportError(e); }
155
+ finally {
156
+ effectStack.pop();
157
+ activeEffect = effectStack.length ? effectStack[effectStack.length - 1] : null;
158
+ }
81
159
  };
82
160
  run();
83
- return function () { stopped = true; };
161
+ return function () {
162
+ stopped = true;
163
+ for (var i = 0; i < deps.length; i++) deps[i].delete(run);
164
+ deps.length = 0;
165
+ };
84
166
  }
85
167
  /** @param {object} t @param {string} k */
86
168
  function track(t, k) {
@@ -90,6 +172,8 @@
90
172
  var s = m.get(k);
91
173
  if (!s) { s = new Set(); m.set(k, s); }
92
174
  s.add(activeEffect);
175
+ var arr = runDeps.get(activeEffect);
176
+ if (arr) arr.push(s);
93
177
  }
94
178
  /** @param {object} t @param {string} k */
95
179
  function trigger(t, k) {
@@ -101,6 +185,34 @@
101
185
  }
102
186
  /** @param {any} v @returns {boolean} */
103
187
  function isObj(v) { return v !== null && typeof v === 'object'; }
188
+ /**
189
+ * Make a child scope that inherits a parent while owning the given
190
+ * variables. Uses defineProperty because plain assignment on an
191
+ * object whose prototype is a reactive Proxy would route into the
192
+ * proxy set trap and write the parent instead of the child.
193
+ *
194
+ * @example
195
+ * var row = childScope(state, { c: item, i: 0 });
196
+ * row.c; // item, owned by row, parent untouched
197
+ *
198
+ * @param {any} scope Parent scope on the prototype chain.
199
+ * @param {Record<string, any>} vars Variables owned by the child.
200
+ * @returns {any} Child scope.
201
+ */
202
+ function childScope(scope, vars) {
203
+ var o = Object.create(scope);
204
+ for (var k in vars) {
205
+ if (Object.prototype.hasOwnProperty.call(vars, k)) {
206
+ Object.defineProperty(o, k, {
207
+ value: vars[k],
208
+ writable: true,
209
+ enumerable: true,
210
+ configurable: true
211
+ });
212
+ }
213
+ }
214
+ return o;
215
+ }
104
216
  /**
105
217
  * Read a dotted path from a scope, walking the prototype chain for the
106
218
  * first segment (so child scopes inherit parent values). Returns
@@ -121,20 +233,28 @@
121
233
  if (!path) return undefined;
122
234
  var parts = String(path).split('.');
123
235
  var head = parts[0].trim();
236
+ if (badSeg(head)) return undefined;
124
237
  if (head === '$store') {
125
238
  var sn = parts[1];
126
239
  var st = stores.get(sn);
127
240
  if (!st) return undefined;
128
241
  var cur2 = st;
129
242
  for (var k = 2; k < parts.length; k++) {
243
+ if (badSeg(parts[k])) return undefined;
130
244
  if (!isObj(cur2)) return undefined;
131
245
  cur2 = cur2[parts[k]];
132
246
  }
133
247
  return cur2;
134
248
  }
135
- if (!(head in Object(scope))) return undefined;
249
+ if (!(head in Object(scope))) {
250
+ // Track the miss so late-added keys (framework $error, user keys
251
+ // declared after first render) still wake this reader up.
252
+ if (typeof head === 'string' && isObj(scope)) track(scope, head);
253
+ return undefined;
254
+ }
136
255
  var cur = scope[head];
137
256
  for (var i = 1; i < parts.length; i++) {
257
+ if (badSeg(parts[i])) return undefined;
138
258
  if (!isObj(cur) && typeof cur !== 'function') return undefined;
139
259
  cur = cur[parts[i]];
140
260
  }
@@ -143,6 +263,9 @@
143
263
  /**
144
264
  * Write a dotted path into a scope, creating intermediate objects.
145
265
  * Supports $store paths the same way getPath reads them.
266
+ * Writes follow the prototype chain: when the head key already
267
+ * exists on an ancestor scope, the ancestor updates instead of the
268
+ * child shadowing it. Fresh keys land directly on the scope.
146
269
  *
147
270
  * @example
148
271
  * var s = {};
@@ -154,6 +277,12 @@
154
277
  */
155
278
  function setPath(scope, path, val) {
156
279
  var parts = String(path).split('.');
280
+ for (var g = 0; g < parts.length; g++) {
281
+ if (badSeg(parts[g].trim())) {
282
+ if (DEV && typeof console !== 'undefined') console.warn('[laghu] blocked unsafe path "' + path + '"');
283
+ return;
284
+ }
285
+ }
157
286
  if (parts[0].trim() === '$store') {
158
287
  var st = stores.get(parts[1]);
159
288
  if (!st) return;
@@ -165,7 +294,16 @@
165
294
  c2[parts[parts.length - 1]] = val;
166
295
  return;
167
296
  }
168
- var cur = scope;
297
+ // Write through the scope chain: when the head key already lives on
298
+ // an ancestor (child row scopes, $event wrappers), update the owner
299
+ // instead of shadowing it on the child. Fresh keys land on scope.
300
+ var base = scope;
301
+ var node = scope;
302
+ while (node && node !== Object.prototype) {
303
+ if (Object.prototype.hasOwnProperty.call(node, parts[0])) { base = node; break; }
304
+ node = Object.getPrototypeOf(node);
305
+ }
306
+ var cur = base;
169
307
  for (var i = 0; i < parts.length - 1; i++) {
170
308
  var key = parts[i];
171
309
  if (!isObj(cur[key])) cur[key] = {};
@@ -183,12 +321,18 @@
183
321
  * state.user.name = 'Grace'; // triggers effects that read user or user.name
184
322
  * state.tags.push('x'); // triggers effects that read tags or length
185
323
  *
324
+ * Idempotent: passing an already reactive object returns it as is
325
+ * instead of stacking proxy on proxy.
326
+ *
186
327
  * @param {object} obj Plain object or array to make reactive.
187
328
  * @returns {any} Reactive proxy around the same data.
188
329
  */
189
330
  function reactive(obj) {
190
331
  if (!isObj(obj)) return obj;
191
- return new Proxy(obj, {
332
+ if (proxySet.has(obj)) return obj;
333
+ var hit = proxyCache.get(obj);
334
+ if (hit) return hit;
335
+ var proxy = new Proxy(obj, {
192
336
  get: function (t, p, r) {
193
337
  if (typeof p === 'string') track(t, p);
194
338
  var v = Reflect.get(t, p, r);
@@ -197,15 +341,28 @@
197
341
  set: function (/** @type {any} */ t, p, v) {
198
342
  var old = t[/** @type {string} */ (p)];
199
343
  var ok = Reflect.set(t, p, v);
200
- if (old !== v && typeof p === 'string') trigger(t, p);
344
+ if (old !== v && typeof p === 'string') {
345
+ trigger(t, p);
346
+ // Miss-reads subscribe on the proxy itself (the raw target is
347
+ // unreachable from outside), so wake those subscribers too.
348
+ var px = proxyCache.get(t);
349
+ if (px) trigger(px, p);
350
+ }
201
351
  return ok;
202
352
  },
203
353
  deleteProperty: function (t, p) {
204
354
  var ok = Reflect.deleteProperty(t, p);
205
- if (typeof p === 'string') trigger(t, p);
355
+ if (typeof p === 'string') {
356
+ trigger(t, p);
357
+ var px = proxyCache.get(t);
358
+ if (px) trigger(px, p);
359
+ }
206
360
  return ok;
207
361
  }
208
362
  });
363
+ proxyCache.set(obj, proxy);
364
+ proxySet.add(proxy);
365
+ return proxy;
209
366
  }
210
367
  /** @param {string} s @param {string} op @returns {string[]} */
211
368
  function splitTop(s, op) {
@@ -214,27 +371,77 @@
214
371
  var c = s[i];
215
372
  if (q) { cur += c; if (c === q && s[i - 1] !== '\\') q = null; continue; }
216
373
  if (c === '"' || c === "'") { q = c; cur += c; continue; }
217
- if (c === '(') depth++;
218
- if (c === ')') depth--;
374
+ if (c === '(' || c === '[') depth++;
375
+ if (c === ')' || c === ']') depth--;
219
376
  if (depth === 0 && s.slice(i, i + op.length) === op) { out.push(cur); cur = ''; i += op.length - 1; continue; }
220
377
  cur += c;
221
378
  }
222
379
  out.push(cur);
223
380
  return out;
224
381
  }
382
+ /**
383
+ * Find the last top-level operator char outside quotes and parens.
384
+ * Position 0 never counts, so leading signs stay unary.
385
+ * @param {string} s @param {string} chars
386
+ * @returns {number} Index, or -1 when absent.
387
+ */
388
+ function lastTopOp(s, chars) {
389
+ var depth = 0, q = null;
390
+ for (var i = s.length - 1; i > 0; i--) {
391
+ var c = s[i];
392
+ if (q) { if (c === q && s[i - 1] !== '\\') q = null; continue; }
393
+ if (c === '"' || c === "'") { q = c; continue; }
394
+ if (c === ')') depth++;
395
+ if (c === '(') depth--;
396
+ if (depth === 0 && chars.indexOf(c) >= 0) return i;
397
+ }
398
+ return -1;
399
+ }
400
+ /**
401
+ * Split a ternary at its top-level ? and :. Returns null when the
402
+ * string holds no top-level question mark.
403
+ * @param {string} s
404
+ * @returns {Array<string>|null} [cond, yes, no] or null.
405
+ */
406
+ function splitTernary(s) {
407
+ var depth = 0, q = null, qi = -1;
408
+ var i;
409
+ for (i = 0; i < s.length; i++) {
410
+ var c = s[i];
411
+ if (q) { if (c === q && s[i - 1] !== '\\') q = null; continue; }
412
+ if (c === '"' || c === "'") { q = c; continue; }
413
+ if (c === '(' || c === '[') depth++;
414
+ if (c === ')' || c === ']') depth--;
415
+ if (c === '?' && depth === 0) { qi = i; break; }
416
+ }
417
+ if (qi < 0) return null;
418
+ depth = 0; q = null;
419
+ for (i = qi + 1; i < s.length; i++) {
420
+ var d = s[i];
421
+ if (q) { if (d === q && s[i - 1] !== '\\') q = null; continue; }
422
+ if (d === '"' || d === "'") { q = d; continue; }
423
+ if (d === '(' || d === '[') depth++;
424
+ if (d === ')' || d === ']') depth--;
425
+ if (d === ':' && depth === 0) return [s.slice(0, qi), s.slice(qi + 1, i), s.slice(i + 1)];
426
+ }
427
+ return null;
428
+ }
225
429
  /**
226
430
  * Evaluate a small expression against a scope without new Function,
227
431
  * so it runs under strict Content Security Policy.
228
432
  *
229
- * Supported: dotted paths, string/number/true/false/null literals,
230
- * not (!), comparisons (>, <, >=, <=, ==, !=, ===, !==),
231
- * and/or (&&, ||), and no-argument calls such as "reset()".
433
+ * Supported: dotted paths, bracket indexes (list[0], map["key"]),
434
+ * string/number/true/false/null literals, not (!), arithmetic
435
+ * (+ - * / %), comparisons, and/or (&&, ||), ternary (a ? b : c),
436
+ * and calls with evaluated arguments such as "total(qty)".
232
437
  * Anything else returns undefined. Unknown method names warn in dev.
233
438
  *
234
439
  * @example
235
440
  * Laghu.eval({ count: 6 }, 'count > 5'); // true
236
441
  * Laghu.eval({ user: { name: 'Ada' } }, 'user.name'); // 'Ada'
237
442
  * Laghu.eval({ open: false }, '!open'); // true
443
+ * Laghu.eval({ price: 3, qty: 4 }, 'price * qty'); // 12
444
+ * Laghu.eval({ t: 1 }, 't > 0 ? "up" : "down"'); // 'up'
238
445
  *
239
446
  * @param {any} scope Scope to resolve paths and methods against.
240
447
  * @param {string} expr Expression text from a data-* attribute.
@@ -243,6 +450,8 @@
243
450
  function evalExpr(scope, expr) {
244
451
  var s = String(expr).trim();
245
452
  if (!s) return undefined;
453
+ var ter0 = splitTernary(s);
454
+ if (ter0) return evalExpr(scope, ter0[0]) ? evalExpr(scope, ter0[1]) : evalExpr(scope, ter0[2]);
246
455
  var or = splitTop(s, '||');
247
456
  if (or.length > 1) {
248
457
  var r;
@@ -267,19 +476,83 @@
267
476
  if (op === '===') return L === R;
268
477
  return L !== R;
269
478
  }
479
+ var ai = lastTopOp(s, '+-');
480
+ if (ai > 0 && s.slice(0, ai).trim() && s.slice(ai + 1).trim()) {
481
+ var lop = s[ai];
482
+ /** @type {any} */ var la = evalExpr(scope, s.slice(0, ai));
483
+ /** @type {any} */ var ra = evalExpr(scope, s.slice(ai + 1));
484
+ return lop === '+' ? la + ra : la - ra;
485
+ }
486
+ var mi = lastTopOp(s, '*/%');
487
+ if (mi > 0 && s.slice(0, mi).trim() && s.slice(mi + 1).trim()) {
488
+ var mop = s[mi];
489
+ /** @type {any} */ var lm = evalExpr(scope, s.slice(0, mi));
490
+ /** @type {any} */ var rm = evalExpr(scope, s.slice(mi + 1));
491
+ if (mop === '*') return lm * rm;
492
+ if (mop === '/') return lm / rm;
493
+ return lm % rm;
494
+ }
270
495
  if (s[0] === '!') return !evalExpr(scope, s.slice(1));
496
+ if (s[0] === '-') return -evalExpr(scope, s.slice(1));
497
+ // parenthesized group: (a + b) * c. Only when the first paren
498
+ // matches the final char, so calls fall through below.
499
+ if (s[0] === '(') {
500
+ var gd = 0, gq = null, gm = -1, gi;
501
+ for (gi = 0; gi < s.length; gi++) {
502
+ var gc = s[gi];
503
+ if (gq) { if (gc === gq && s[gi - 1] !== '\\') gq = null; continue; }
504
+ if (gc === '"' || gc === "'") { gq = gc; continue; }
505
+ if (gc === '(') gd++;
506
+ if (gc === ')') { gd--; if (gd === 0) { gm = gi; break; } }
507
+ }
508
+ if (gm === s.length - 1) return evalExpr(scope, s.slice(1, -1));
509
+ }
271
510
  if ((s[0] === '"' && s[s.length - 1] === '"') || (s[0] === "'" && s[s.length - 1] === "'")) return s.slice(1, -1);
272
511
  if (/^-?\d+(\.\d+)?$/.test(s)) return Number(s);
273
512
  if (s === 'true') return true;
274
513
  if (s === 'false') return false;
275
514
  if (s === 'null') return null;
276
515
  if (s === 'undefined') return undefined;
277
- var call = s.match(/^([\w.$]+)\(\)$/);
278
- if (call) {
279
- var fn = getPath(scope, call[1]);
280
- if (typeof fn === 'function') return fn.call(scope);
281
- if (DEV && typeof console !== 'undefined') console.warn('[laghu] unknown method "' + s + '"');
282
- return undefined;
516
+ // calls with evaluated arguments: name(a, b). The name resolves
517
+ // through the blocked-segment guard, so constructor-style gadgets
518
+ // stay unreachable here too.
519
+ if (s.charAt(s.length - 1) === ')') {
520
+ var op2 = s.indexOf('(');
521
+ if (op2 > 0) {
522
+ var depth = 0, qq = null, match = -1, k;
523
+ for (k = op2; k < s.length; k++) {
524
+ var ch = s[k];
525
+ if (qq) { if (ch === qq && s[k - 1] !== '\\') qq = null; continue; }
526
+ if (ch === '"' || ch === "'") { qq = ch; continue; }
527
+ if (ch === '(') depth++;
528
+ if (ch === ')') { depth--; if (depth === 0) { match = k; break; } }
529
+ }
530
+ if (match === s.length - 1) {
531
+ var cname = s.slice(0, op2);
532
+ if (/^[\w.$]+$/.test(cname)) {
533
+ var fn = getPath(scope, cname);
534
+ if (typeof fn === 'function') {
535
+ var raw = s.slice(op2 + 1, match).trim();
536
+ var args = raw ? splitTop(raw, ',').map(function (a) { return evalExpr(scope, a); }) : [];
537
+ return fn.apply(scope, args);
538
+ }
539
+ if (DEV && typeof console !== 'undefined') console.warn('[laghu] unknown method "' + s + '"');
540
+ return undefined;
541
+ }
542
+ }
543
+ }
544
+ }
545
+ // bracket index: list[0], map["key"]. Falls back to a plain path.
546
+ var br = s.match(/^(.*)\[(\d+|"(?:[^"]*)"|'(?:[^']*)')\]$/);
547
+ if (br && br[1]) {
548
+ var base = evalExpr(scope, br[1]);
549
+ if (base === null || base === undefined) return undefined;
550
+ /** @type {any} */
551
+ var key = br[2];
552
+ if (key[0] === '"' || key[0] === "'") key = key.slice(1, -1);
553
+ else key = Number(key);
554
+ if (typeof key === 'string' && badSeg(key)) return undefined;
555
+ return base[key];
283
556
  }
284
557
  return getPath(scope, s);
285
558
  }
@@ -312,13 +585,26 @@
312
585
  var name = s.replace(/\(\)$/, '');
313
586
  var fn = actions[name] || getPath(scope, name);
314
587
  if (typeof fn === 'function') { fn.call(scope); return; }
588
+ if (/^[A-Za-z_$][\w.$-]*$/.test(s)) {
589
+ // Plain name (kebab-case allowed): an action or state function
590
+ // reference. Never an expression, so hyphens stay literal here
591
+ // even though they mean subtraction inside evalExpr strings.
592
+ unknownAction(s);
593
+ return;
594
+ }
315
595
  var v = evalExpr(scope, s);
316
596
  if (typeof v === 'function') v.call(scope);
317
- else if (v === undefined) {
318
- if (DEV && typeof console !== 'undefined') console.warn('[laghu] unknown action "' + s + '"');
319
- if (typeof CustomEvent !== 'undefined' && typeof document !== 'undefined' && document.dispatchEvent) {
320
- document.dispatchEvent(new CustomEvent('laghu:unknown-action', { detail: { name: s } }));
321
- }
597
+ else if (v === undefined) unknownAction(s);
598
+ }
599
+ /**
600
+ * Loud unknown-action path: dev warning plus a document event every
601
+ * time, so typos fail visibly in any environment.
602
+ * @param {string} s
603
+ */
604
+ function unknownAction(s) {
605
+ if (DEV && typeof console !== 'undefined') console.warn('[laghu] unknown action "' + s + '"');
606
+ if (typeof CustomEvent !== 'undefined' && typeof document !== 'undefined' && document.dispatchEvent) {
607
+ document.dispatchEvent(new CustomEvent('laghu:unknown-action', { detail: { name: s } }));
322
608
  }
323
609
  }
324
610
  /** @param {Element} el @param {Function} fn */
@@ -327,16 +613,35 @@
327
613
  if (!list) { list = []; /** @type {any} */ (el)._bcu = list; }
328
614
  list.push(fn);
329
615
  }
616
+ /**
617
+ * One binding per element per kind. hydrate rescans whole subtrees
618
+ * (template inserts, swaps), so without this guard already wired nodes
619
+ * would collect duplicate effects and listeners. Returns true when
620
+ * this kind was already wired on this element.
621
+ *
622
+ * @param {Element} el Element carrying the attribute.
623
+ * @param {string} key Binding kind, for example "click" or "bind".
624
+ * @returns {boolean} True when already wired, meaning skip.
625
+ */
626
+ function claimed(el, key) {
627
+ var host = /** @type {any} */ (el);
628
+ var map = host._bH || (host._bH = {});
629
+ if (map[key]) return true;
630
+ map[key] = 1;
631
+ return false;
632
+ }
330
633
  /** @param {Element} root */
331
634
  function destroyTree(root) {
332
635
  if (/** @type {any} */ (root)._bcu) {
333
636
  /** @type {any} */ (root)._bcu.forEach(function (/** @type {Function} */ fn) { try { fn(); } catch (e) {} });
334
637
  /** @type {any} */ (root)._bcu = [];
335
638
  }
639
+ /** @type {any} */ (root)._bH = undefined;
336
640
  var kids = root.querySelectorAll ? root.querySelectorAll('*') : [];
337
641
  for (var i = 0; i < kids.length; i++) {
338
642
  var c = /** @type {any} */ (kids[i]);
339
643
  if (c._bcu) { c._bcu.forEach(function (/** @type {Function} */ fn) { try { fn(); } catch (e) {} }); c._bcu = []; }
644
+ c._bH = undefined;
340
645
  if (c._bReq) { try { c._bReq.abort(); } catch (e) {} c._bReq = null; }
341
646
  }
342
647
  var self = /** @type {any} */ (root);
@@ -539,7 +844,8 @@
539
844
  * data-html (innerHTML through the sanitize hook), data-bind:attr,
540
845
  * data-class:name, data-style:prop, data-model (two-way form binding),
541
846
  * data-show and data-if (hidden toggle on elements, real attach and
542
- * detach on template tags), template data-for lists with data-key,
847
+ * detach on template tags), template data-for lists with item-relative
848
+ * data-key (rows keyed by item property, stable across reorder),
543
849
  * data-click, data-on:event with .prevent .stop .once, data-init,
544
850
  * custom directives, and data-get/post/put/patch/delete fetch blocks.
545
851
  *
@@ -561,17 +867,25 @@
561
867
  function hydrate(root, state, actions) {
562
868
  actions = actions || {};
563
869
  var scope = state;
870
+ // Seed the shared fetch-error slot before any binding reads it, so
871
+ // data-bind="$error.status" subscribes on first render instead of
872
+ // staying blind. setPath lands it on the owning scope.
873
+ try {
874
+ if (isObj(scope) && !('$error' in Object(scope))) setPath(scope, '$error', null);
875
+ } catch (e) {}
564
876
  if (root instanceof Element && /** @type {any} */ (root).hasAttribute && /** @type {any} */ (root).hasAttribute('data-scope') && !/** @type {any} */ (root)._bScope) {
565
877
  /** @type {any} */ (root)._bScope = scope;
566
878
  }
567
879
  root.querySelectorAll('[data-bind],[data-text]').forEach(function (node) {
568
880
  var el = /** @type {HTMLElement} */ (node);
881
+ if (claimed(el, 'bind')) return;
569
882
  var path = el.getAttribute('data-bind') || el.getAttribute('data-text') || '';
570
883
  var stop = effect(function () { el.textContent = String(evalExpr(scope, path) ?? ''); });
571
884
  onCleanup(el, stop);
572
885
  });
573
886
  root.querySelectorAll('[data-html]').forEach(function (node) {
574
887
  var el = /** @type {HTMLElement} */ (node);
888
+ if (claimed(el, 'html')) return;
575
889
  var path = el.getAttribute('data-html') || '';
576
890
  var stop = effect(function () { el.innerHTML = sanitize(String(evalExpr(scope, path) ?? '')); });
577
891
  onCleanup(el, stop);
@@ -581,20 +895,27 @@
581
895
  Array.prototype.forEach.call(el.attributes || [], function (/** @type {Attr} */ a) {
582
896
  var an = a.name;
583
897
  if (an.indexOf('data-bind:') === 0) {
898
+ if (claimed(el, 'a:' + an)) return;
584
899
  var attr = an.slice('data-bind:'.length);
585
900
  var expr = a.value;
586
901
  var stop = effect(function () {
587
902
  var v = evalExpr(scope, expr);
588
903
  if (v === false || v === null || v === undefined) el.removeAttribute(attr);
904
+ else if (URL_ATTRS[attr.toLowerCase()] && unsafeUrl(v)) {
905
+ el.removeAttribute(attr);
906
+ if (DEV && typeof console !== 'undefined') console.warn('[laghu] blocked scriptable URL for "' + attr + '"');
907
+ }
589
908
  else el.setAttribute(attr, String(v));
590
909
  });
591
910
  onCleanup(el, stop);
592
911
  } else if (an.indexOf('data-class:') === 0) {
912
+ if (claimed(el, 'a:' + an)) return;
593
913
  var cls = an.slice('data-class:'.length);
594
914
  var e2 = a.value;
595
915
  var s2 = effect(function () { el.classList.toggle(cls, !!evalExpr(scope, e2)); });
596
916
  onCleanup(el, s2);
597
917
  } else if (an.indexOf('data-style:') === 0) {
918
+ if (claimed(el, 'a:' + an)) return;
598
919
  var prop = an.slice('data-style:'.length);
599
920
  var e3 = a.value;
600
921
  var s3 = effect(function () {
@@ -608,6 +929,7 @@
608
929
  // two-way model: text/number/checkbox/radio/select/textarea
609
930
  root.querySelectorAll('[data-model]').forEach(function (node) {
610
931
  var el = /** @type {any} */ (node);
932
+ if (claimed(el, 'model')) return;
611
933
  var path = el.getAttribute('data-model') || '';
612
934
  var isCheck = el.type === 'checkbox';
613
935
  var isRadio = el.type === 'radio';
@@ -657,6 +979,7 @@
657
979
  });
658
980
  root.querySelectorAll('[data-show]').forEach(function (node) {
659
981
  var el = /** @type {HTMLElement} */ (node);
982
+ if (claimed(el, 'show')) return;
660
983
  var expr = el.getAttribute('data-show') || '';
661
984
  var stop = effect(function () { el.hidden = !evalExpr(scope, expr); });
662
985
  onCleanup(el, stop);
@@ -664,6 +987,7 @@
664
987
  Array.prototype.forEach.call(root.querySelectorAll('[data-if]'), function (node) {
665
988
  var el = /** @type {HTMLElement} */ (node);
666
989
  if (el.tagName === 'TEMPLATE') return;
990
+ if (claimed(el, 'if')) return;
667
991
  var expr = el.getAttribute('data-if') || '';
668
992
  var stop = effect(function () { el.hidden = !evalExpr(scope, expr); });
669
993
  onCleanup(el, stop);
@@ -671,6 +995,7 @@
671
995
  Array.prototype.forEach.call(root.querySelectorAll('template[data-if]'), function (node) {
672
996
  var tpl = /** @type {HTMLTemplateElement} */ (node);
673
997
  var expr = tpl.getAttribute('data-if') || '';
998
+ if (claimed(tpl, 'tif')) return;
674
999
  var anchor = document.createComment('b-if');
675
1000
  if (tpl.parentNode) tpl.parentNode.insertBefore(anchor, tpl);
676
1001
  /** @type {any} */ var frag = null;
@@ -697,6 +1022,7 @@
697
1022
  Array.prototype.forEach.call(root.querySelectorAll('template[data-for]'), function (node) {
698
1023
  var tpl = /** @type {HTMLTemplateElement} */ (node);
699
1024
  var spec = tpl.getAttribute('data-for') || '';
1025
+ if (claimed(tpl, 'tfor')) return;
700
1026
  var mm = spec.match(/^\s*(\w+)(?:\s*,\s*(\w+))?\s+in\s+(.+)\s*$/);
701
1027
  if (!mm) { if (DEV && typeof console !== 'undefined') console.warn('[laghu] bad data-for "' + spec + '"'); return; }
702
1028
  var itemVar = mm[1]; var idxVar = mm[2]; var listExpr = mm[3];
@@ -714,41 +1040,77 @@
714
1040
  var order = [];
715
1041
  for (var i = 0; i < list.length; i++) {
716
1042
  var item = list[i];
717
- var tmpScope = Object.assign(Object.create(scope), {});
718
- tmpScope[itemVar] = item;
719
- var key = keyExpr ? evalExpr(tmpScope, keyExpr) : i;
720
- if (key === undefined || key === null) key = i;
1043
+ // data-key names a property of the item itself ("id"), with a
1044
+ // scope lookup fallback, then the index. Item-relative keys
1045
+ // keep rows stable across reorder; index keys only append.
1046
+ /** @type {any} */
1047
+ var key = i;
1048
+ if (keyExpr) {
1049
+ var ki = evalExpr(item, keyExpr);
1050
+ if (ki === undefined || ki === null) {
1051
+ /** @type {Record<string, any>} */
1052
+ var tmpVars = {};
1053
+ tmpVars[itemVar] = item;
1054
+ ki = evalExpr(childScope(scope, tmpVars), keyExpr);
1055
+ }
1056
+ if (ki !== undefined && ki !== null) key = ki;
1057
+ }
721
1058
  seen.add(key);
722
1059
  order.push(key);
723
1060
  var row = rows.get(key);
724
1061
  if (!row) {
725
- var rs = Object.create(scope);
726
- rs[itemVar] = item;
727
- if (idxVar) rs[idxVar] = i;
1062
+ /** @type {Record<string, any>} */
1063
+ var rsVars = {};
1064
+ rsVars[itemVar] = item;
1065
+ if (idxVar) rsVars[idxVar] = i;
1066
+ var rs = childScope(scope, rsVars);
728
1067
  var f = /** @type {DocumentFragment} */ (tpl.content.cloneNode(true));
729
1068
  var holder = document.createElement('span');
730
1069
  holder.style.display = 'contents';
731
- if (keyExpr) holder.setAttribute('data-bkey', String(key));
1070
+ holder.setAttribute('data-bkey', String(key));
732
1071
  holder.appendChild(f);
733
1072
  parent.insertBefore(holder, anchor);
734
1073
  hydrate(holder, rs, actions);
735
1074
  rows.set(key, { nodes: [holder], scope: rs });
736
1075
  } else {
1076
+ // Same key, possibly new identity (replaced array) or new
1077
+ // index (reorder). Same refs update nothing: fine-grained
1078
+ // bindings already show the mutation. New identity rebinds
1079
+ // the row so effects read the new objects; reorder alone
1080
+ // only moves nodes and keeps focus.
1081
+ var prevItem = row.scope[itemVar];
1082
+ var prevIdx = idxVar ? row.scope[idxVar] : -1;
737
1083
  row.scope[itemVar] = item;
738
1084
  if (idxVar) row.scope[idxVar] = i;
1085
+ if (prevItem !== item || (idxVar && prevIdx !== i)) {
1086
+ var h0 = row.nodes[0];
1087
+ if (h0 instanceof Element) { destroyTree(h0); hydrate(h0, row.scope, actions); }
1088
+ }
1089
+ }
1090
+ }
1091
+ // Move holders only when DOM order differs from desired order.
1092
+ // Spurious moves report as remove+add to MutationObserver, which
1093
+ // would strip the moved rows' bindings, so never move blindly.
1094
+ var seq = [];
1095
+ var chn = parent.childNodes;
1096
+ for (var q = 0; q < chn.length; q++) {
1097
+ var hn = chn[q];
1098
+ if (hn.nodeType === 1 && /** @type {Element} */ (hn).hasAttribute('data-bkey')) {
1099
+ seq.push(/** @type {Element} */ (hn).getAttribute('data-bkey'));
739
1100
  }
740
1101
  }
741
- // reorder without destroy (preserves focus/input): iterate desired order
742
- for (var k = 0; k < order.length; k++) {
743
- var r2 = rows.get(order[k]);
744
- if (r2) {
745
- var h2 = /** @type {HTMLElement} */ (r2.nodes[0]);
746
- var want = anchor;
747
- // find reference: next desired sibling already placed? simplest: insert each in order before anchor sequentially = stable
748
- if (h2.nextSibling !== want || true) parent.insertBefore(h2, want);
1102
+ var sameOrder = seq.length === order.length;
1103
+ if (sameOrder) {
1104
+ for (var q2 = 0; q2 < order.length; q2++) {
1105
+ if (seq[q2] !== String(order[q2])) { sameOrder = false; break; }
1106
+ }
1107
+ }
1108
+ if (!sameOrder) {
1109
+ for (var k = 0; k < order.length; k++) {
1110
+ var r2 = rows.get(order[k]);
1111
+ if (r2) parent.insertBefore(/** @type {HTMLElement} */ (r2.nodes[0]), anchor);
749
1112
  }
750
1113
  }
751
- // fix order: the loop inserts each holder before the anchor in sequence
752
1114
  rows.forEach(function (row2, kk) {
753
1115
  if (!seen.has(kk)) {
754
1116
  row2.nodes.forEach(function (nn) {
@@ -765,6 +1127,7 @@
765
1127
  // actions: data-click + data-on:event(.prevent.stop.once)
766
1128
  root.querySelectorAll('[data-click]').forEach(function (node) {
767
1129
  var el = /** @type {HTMLElement} */ (node);
1130
+ if (claimed(el, 'click')) return;
768
1131
  var code = el.getAttribute('data-click') || '';
769
1132
  var spec = el.getAttribute('data-trigger') || 'click';
770
1133
  bindTrigger(el, spec, function () { execAction(scope, actions || {}, code); });
@@ -773,6 +1136,7 @@
773
1136
  var el = /** @type {HTMLElement} */ (node);
774
1137
  Array.prototype.forEach.call(el.attributes || [], function (/** @type {Attr} */ a) {
775
1138
  if (a.name.indexOf('data-on:') === 0) {
1139
+ if (claimed(el, 'on:' + a.name)) return;
776
1140
  var raw = a.name.slice('data-on:'.length);
777
1141
  var parts = raw.split('.');
778
1142
  var ev = parts[0];
@@ -782,7 +1146,7 @@
782
1146
  bindTrigger(el, spec, function (/** @type {Event} */ e) {
783
1147
  if (mods.indexOf('prevent') >= 0) e.preventDefault();
784
1148
  if (mods.indexOf('stop') >= 0) e.stopPropagation();
785
- var s2 = Object.create(scope); s2.$event = e;
1149
+ var s2 = childScope(scope, { $event: e });
786
1150
  execAction(s2, actions || {}, code);
787
1151
  if (mods.indexOf('once') >= 0) { try { /** @type {any} */ (el).removeAttribute(a.name); } catch (err) {} }
788
1152
  });
@@ -792,16 +1156,18 @@
792
1156
  // data-init runs once as action
793
1157
  root.querySelectorAll('[data-init]').forEach(function (node) {
794
1158
  var el = /** @type {HTMLElement} */ (node);
1159
+ if (claimed(el, 'init')) return;
795
1160
  var code = el.getAttribute('data-init') || '';
796
- try { execAction(scope, actions || {}, code); } catch (e) {}
1161
+ try { execAction(scope, actions || {}, code); } catch (e) { reportError(e); }
797
1162
  });
798
1163
  directives.forEach(function (fn, name) {
799
1164
  root.querySelectorAll('[data-' + name + ']').forEach(function (node) {
800
1165
  var el = /** @type {HTMLElement} */ (node);
1166
+ if (claimed(el, 'dir:' + name)) return;
801
1167
  try {
802
1168
  var out = fn({ el: el, get: function () { return evalExpr(scope, el.getAttribute('data-' + name) || ''); }, effect: effect, scope: scope });
803
1169
  if (typeof out === 'function') onCleanup(el, out);
804
- } catch (e) { if (DEV && typeof console !== 'undefined') console.error('[laghu] directive ' + name, e); }
1170
+ } catch (e) { reportError(e); }
805
1171
  });
806
1172
  });
807
1173
  // fetch triggers: data-get/post/put/patch/delete
@@ -817,16 +1183,24 @@
817
1183
  if (!url && !el.hasAttribute('data-get') && !el.hasAttribute('data-post') && !el.hasAttribute('data-put') && !el.hasAttribute('data-patch') && !el.hasAttribute('data-delete')) return;
818
1184
  if (el.hasAttribute('data-method')) method = String(el.getAttribute('data-method') || method).toUpperCase();
819
1185
  if (!method) method = 'GET';
820
- setupFetch(el, scope, method, url);
1186
+ if (claimed(el, 'fetch')) return;
1187
+ setupFetch(el, scope, method, url, actions);
821
1188
  });
822
1189
  }
823
1190
  /**
824
- * @param {HTMLElement} el @param {any} scope @param {string} method @param {string} url
1191
+ * @param {HTMLElement} el @param {any} scope @param {string} method @param {string} url @param {Record<string, Function>} [actions]
825
1192
  */
826
- function setupFetch(el, scope, method, url) {
1193
+ function setupFetch(el, scope, method, url, actions) {
827
1194
  var targetSel = el.getAttribute('data-target') || '';
828
1195
  var swap = el.getAttribute('data-swap') || 'inner';
829
1196
  var triggerSpec = el.getAttribute('data-trigger') || defaultEvent(el);
1197
+ /** @type {any} */ (el)._bActions = actions || {};
1198
+ // Seed the shared error slot so bindings like data-bind="$error.status"
1199
+ // subscribe before the first request ever runs. Writes land on the
1200
+ // owning scope through setPath, never as a child shadow.
1201
+ var hasErr = false;
1202
+ try { hasErr = ('$error' in Object(scope)); } catch (e) {}
1203
+ if (!hasErr) setPath(scope, '$error', null);
830
1204
  var fire = function () { doFetch(el, scope, method, url, targetSel, swap); };
831
1205
  bindTrigger(el, triggerSpec, fire);
832
1206
  }
@@ -862,24 +1236,38 @@
862
1236
  function doFetch(el, scope, method, url, targetSel, swap) {
863
1237
  var m = parseTrigger(el.getAttribute('data-trigger') || defaultEvent(el), el);
864
1238
  var self = /** @type {any} */ (el);
865
- if (self._bInflight && (m.queue === 'first' || m.queue === 'none')) return;
866
- if (m.queue === 'last' && self._bReq) { try { self._bReq.abort(); } catch (e) {} }
1239
+ if (!self._bReqs) self._bReqs = new Set();
1240
+ if (self._bN > 0 && (m.queue === 'first' || m.queue === 'none')) return;
1241
+ if (m.queue === 'last') {
1242
+ self._bReqs.forEach(function (/** @type {any} */ c) { try { c.abort(); } catch (e) {} });
1243
+ self._bReqs.clear();
1244
+ }
867
1245
  var ctrl = typeof AbortController !== 'undefined' ? new AbortController() : null;
868
- if (ctrl) self._bReq = ctrl;
869
- self._bInflight = true;
1246
+ if (ctrl) self._bReqs.add(ctrl);
1247
+ self._bN = (self._bN || 0) + 1;
1248
+ var timedOut = false;
1249
+ var timeoutMs = parseMs(el.getAttribute('data-timeout') || '');
1250
+ var timer = 0;
1251
+ if (timeoutMs > 0 && ctrl) {
1252
+ timer = /** @type {any} */ (setTimeout(function () {
1253
+ timedOut = true;
1254
+ try { if (ctrl) ctrl.abort(); } catch (e) {}
1255
+ }, timeoutMs));
1256
+ }
1257
+ var actions = self._bActions || {};
870
1258
  var setBusy = function (/** @type {boolean} */ on) {
871
1259
  var indSel = el.getAttribute('data-indicator');
872
1260
  var ind = indSel ? document.querySelector(indSel) : null;
873
1261
  if (ind && ind instanceof HTMLElement) ind.hidden = !on ? true : false;
874
1262
  if (el instanceof HTMLElement) {
875
- if (on) el.setAttribute('data-request', '');
876
- else el.removeAttribute('data-request');
1263
+ if (on) { el.setAttribute('data-request', ''); el.setAttribute('data-pending', ''); }
1264
+ else { el.removeAttribute('data-request'); el.removeAttribute('data-pending'); }
877
1265
  if (el.hasAttribute('data-disable')) el.toggleAttribute('disabled', on);
878
1266
  }
879
1267
  };
880
1268
  setBusy(true);
881
- var before = new CustomEvent('laghu:before-request', { cancelable: true, detail: { url: url, method: method } });
882
- if (!el.dispatchEvent(before)) { self._bInflight = false; setBusy(false); return; }
1269
+ var before = new CustomEvent('laghu:before-request', { bubbles: true, cancelable: true, detail: { url: url, method: method } });
1270
+ if (!el.dispatchEvent(before)) { finish(); return; }
883
1271
  /** @type {any} */
884
1272
  var opts = { method: method, headers: { Accept: 'application/json, text/html' }, signal: ctrl ? ctrl.signal : undefined };
885
1273
  var valsRaw = el.getAttribute('data-vals');
@@ -893,8 +1281,17 @@
893
1281
  for (var k in fv) v[k] = fv[k];
894
1282
  }
895
1283
  if (method === 'GET') {
896
- var qs = new URLSearchParams(v).toString();
897
- if (qs) url += (url.indexOf('?') >= 0 ? '&' : '?') + qs;
1284
+ var parts = [];
1285
+ for (var qk in v) {
1286
+ if (Array.isArray(v[qk])) {
1287
+ for (var qi = 0; qi < v[qk].length; qi++) {
1288
+ parts.push(encodeURIComponent(qk) + '=' + encodeURIComponent(String(v[qk][qi])));
1289
+ }
1290
+ } else {
1291
+ parts.push(encodeURIComponent(qk) + '=' + encodeURIComponent(String(v[qk])));
1292
+ }
1293
+ }
1294
+ if (parts.length) url += (url.indexOf('?') >= 0 ? '&' : '?') + parts.join('&');
898
1295
  } else {
899
1296
  opts.headers['Content-Type'] = 'application/json';
900
1297
  body = JSON.stringify(v);
@@ -912,16 +1309,26 @@
912
1309
  var hh = tryJSON(hRaw) || {};
913
1310
  for (var hk in hh) opts.headers[hk] = hh[hk];
914
1311
  }
915
- var done = function () { self._bInflight = false; if (self._bReq === ctrl) self._bReq = null; setBusy(false); };
1312
+ var finished = false;
1313
+ function finish() {
1314
+ if (finished) return;
1315
+ finished = true;
1316
+ if (timer) clearTimeout(timer);
1317
+ if (ctrl) self._bReqs.delete(ctrl);
1318
+ self._bN = Math.max(0, (self._bN || 1) - 1);
1319
+ setBusy(false);
1320
+ }
916
1321
  fetch(url, opts).then(function (res) {
917
- done();
918
- el.dispatchEvent(new CustomEvent('laghu:after-request', { detail: { status: res.status, url: url } }));
1322
+ finish();
1323
+ el.dispatchEvent(new CustomEvent('laghu:after-request', { bubbles: true, detail: { status: res.status, url: url } }));
919
1324
  if (!res.ok) {
920
- el.dispatchEvent(new CustomEvent('laghu:request-error', { detail: { status: res.status, url: url } }));
1325
+ setPath(scope, '$error', { status: res.status, url: url });
1326
+ el.dispatchEvent(new CustomEvent('laghu:request-error', { bubbles: true, detail: { status: res.status, url: url } }));
921
1327
  return;
922
1328
  }
923
- var ct = res.headers.get('content-type') || '';
924
- if (ct.indexOf('application/json') >= 0) return res.json().then(function (data) {
1329
+ var ct = (res.headers.get('content-type') || '').toLowerCase();
1330
+ if (ct.indexOf('json') >= 0) return res.json().then(function (data) {
1331
+ setPath(scope, '$error', null);
925
1332
  if (data && typeof data === 'object' && !Array.isArray(data)) Object.assign(scope, data);
926
1333
  else setPath(scope, 'result', data);
927
1334
  });
@@ -935,13 +1342,14 @@
935
1342
  }
936
1343
  var target = targetSel ? document.querySelector(targetSel) : el;
937
1344
  if (!target) return;
938
- swapHtml(/** @type {Element} */ (target), html, swap, scope);
1345
+ swapHtml(/** @type {Element} */ (target), html, swap, scope, actions);
939
1346
  });
940
1347
  }).catch(function (err) {
941
- done();
942
- if (err && err.name === 'AbortError') return;
943
- el.dispatchEvent(new CustomEvent('laghu:request-error', { detail: { error: err } }));
944
- if (DEV && typeof console !== 'undefined') console.error('[laghu] fetch', err);
1348
+ finish();
1349
+ if (err && err.name === 'AbortError' && !timedOut) return;
1350
+ setPath(scope, '$error', { message: String((err && err.message) || err), timeout: timedOut });
1351
+ el.dispatchEvent(new CustomEvent('laghu:request-error', { bubbles: true, detail: { error: err, timeout: timedOut } }));
1352
+ reportError(err);
945
1353
  });
946
1354
  }
947
1355
  /**
@@ -992,22 +1400,28 @@
992
1400
  * @param {string} html Response HTML (already selected if data-select used).
993
1401
  * @param {string} swap Mode name, case insensitive.
994
1402
  * @param {any} scope State the swapped-in content hydrates against.
1403
+ * @param {Record<string, Function>} [actions] Named actions for handlers
1404
+ * in the new content. Fetch swaps reuse the requesting element's
1405
+ * actions automatically; without them swapped-in data-click goes
1406
+ * quiet with an unknown-action warning.
995
1407
  */
996
- function swapHtml(target, html, swap, scope) {
1408
+ function swapHtml(target, html, swap, scope, actions) {
1409
+ actions = actions || {};
1410
+ var s = String(swap || 'inner').toLowerCase();
997
1411
  var s = String(swap || 'inner').toLowerCase();
998
- if (s === 'none') { target.dispatchEvent(new CustomEvent('laghu:after-swap', { detail: { swap: s } })); return; }
1412
+ if (s === 'none') { target.dispatchEvent(new CustomEvent('laghu:after-swap', { bubbles: true, detail: { swap: s } })); return; }
999
1413
  var focusId = document.activeElement && /** @type {any} */ (document.activeElement).id ? /** @type {any} */ (document.activeElement).id : null;
1000
1414
  var ae = /** @type {any} */ (document.activeElement);
1001
1415
  var selStart = ae && typeof ae.selectionStart === 'number' ? ae.selectionStart : -1;
1002
1416
  var selEnd = ae && typeof ae.selectionEnd === 'number' ? ae.selectionEnd : -1;
1003
- target.dispatchEvent(new CustomEvent('laghu:before-swap', { cancelable: false, detail: { swap: s } }));
1417
+ target.dispatchEvent(new CustomEvent('laghu:before-swap', { bubbles: true, cancelable: false, detail: { swap: s } }));
1004
1418
  var isMorph = s.indexOf('morph') === 0;
1005
1419
  /** @type {any} */ var M = typeof window !== 'undefined' ? /** @type {any} */ (window).LaghuMorph : null;
1006
1420
  if (isMorph) {
1007
1421
  if (M && M.morph) {
1008
- M.morph(target, sanitize(html), {});
1009
- hydrate(target, scope, {});
1010
- target.dispatchEvent(new CustomEvent('laghu:after-swap', { detail: { swap: s } }));
1422
+ M.morph(target, sanitize(html), { mode: s === 'morph:outer' ? 'outer' : 'inner' });
1423
+ hydrate(target, scope, actions);
1424
+ target.dispatchEvent(new CustomEvent('laghu:after-swap', { bubbles: true, detail: { swap: s } }));
1011
1425
  restoreFocus(focusId, selStart, selEnd);
1012
1426
  return;
1013
1427
  }
@@ -1052,8 +1466,8 @@
1052
1466
  }
1053
1467
  });
1054
1468
  var root = s === 'outer' ? (target.parentNode instanceof Element ? target.parentNode : document.body) : target;
1055
- hydrate(root, scope, {});
1056
- target.dispatchEvent(new CustomEvent('laghu:after-swap', { detail: { swap: s } }));
1469
+ hydrate(root, scope, actions);
1470
+ target.dispatchEvent(new CustomEvent('laghu:after-swap', { bubbles: true, detail: { swap: s } }));
1057
1471
  restoreFocus(focusId, selStart, selEnd);
1058
1472
  }
1059
1473
  /**
@@ -1146,17 +1560,18 @@
1146
1560
  var state = reactive(initial);
1147
1561
  /** @type {any} */ (this).state = state;
1148
1562
  /** @type {any} */ (this).actions = r.actions || {};
1563
+ if (typeof r.onUnmount === 'function') /** @type {any} */ (this)._bOnUnmount = r.onUnmount;
1149
1564
  hydrate(this, state, r.actions || {});
1150
- try { if (typeof r.onMount === 'function') r.onMount.call(state); } catch (e) {}
1565
+ try { if (typeof r.onMount === 'function') r.onMount.call(state); } catch (e) { reportError(e); }
1151
1566
  this.classList.add('is-defined');
1152
1567
  this.dispatchEvent(new CustomEvent('laghu:mount'));
1153
1568
  }
1154
1569
  disconnectedCallback() {
1155
1570
  try {
1156
1571
  var st = /** @type {any} */ (this).state;
1157
- var r2 = null;
1158
- void r2; void st;
1159
- } catch (e) {}
1572
+ var un = /** @type {any} */ (this)._bOnUnmount;
1573
+ if (typeof un === 'function' && st) un.call(st);
1574
+ } catch (e) { reportError(e); }
1160
1575
  destroyTree(this);
1161
1576
  this.dispatchEvent(new CustomEvent('laghu:unmount'));
1162
1577
  /** @type {any} */ (this)._bInit = false;
@@ -1181,6 +1596,12 @@
1181
1596
  function autoObserve() {
1182
1597
  if (observer || typeof MutationObserver === 'undefined') return;
1183
1598
  observer = new MutationObserver(function (muts) {
1599
+ // Nodes moved within one batch appear in both added and removed.
1600
+ // Moving must preserve bindings, so only truly removed nodes die.
1601
+ var added = new Set();
1602
+ muts.forEach(function (m) {
1603
+ m.addedNodes.forEach(function (n) { if (n instanceof Element) added.add(n); });
1604
+ });
1184
1605
  muts.forEach(function (m) {
1185
1606
  m.addedNodes.forEach(function (n) {
1186
1607
  if (n instanceof Element) {
@@ -1188,11 +1609,11 @@
1188
1609
  try {
1189
1610
  var api2 = /** @type {any} */ (window).Laghu;
1190
1611
  if (api2) api2.hydrate(n, parseScope(n.getAttribute('data-scope')), {});
1191
- } catch (e) {}
1612
+ } catch (e) { reportError(e); }
1192
1613
  }
1193
1614
  }
1194
1615
  });
1195
- m.removedNodes.forEach(function (n) { if (n instanceof Element) destroyTree(n); });
1616
+ m.removedNodes.forEach(function (n) { if (n instanceof Element && !added.has(n)) destroyTree(n); });
1196
1617
  });
1197
1618
  });
1198
1619
  observer.observe(document.documentElement, { childList: true, subtree: true });
@@ -1248,6 +1669,75 @@
1248
1669
  if (init !== undefined) { var s = reactive(init); stores.set(name, s); return s; }
1249
1670
  return stores.get(name);
1250
1671
  },
1672
+ /**
1673
+ * Derived value kept fresh by an effect. Call the getter inside
1674
+ * bindings or other effects to subscribe; eager recompute keeps
1675
+ * it simple and predictable at this scale.
1676
+ * @example
1677
+ * var doubled = Laghu.computed(function () { return state.count * 2; });
1678
+ * Laghu.effect(function () { el.textContent = doubled(); });
1679
+ */
1680
+ computed: function (/** @type {Function} */ fn) {
1681
+ var box = reactive({ v: undefined });
1682
+ effect(function () { box.v = fn(); });
1683
+ return function () { return box.v; };
1684
+ },
1685
+ /**
1686
+ * Call back with (next, prev) whenever an expression's value
1687
+ * changes. Reads without subscribing the caller's own effect.
1688
+ * @example
1689
+ * Laghu.watch(state, 'user.name', function (n, o) { console.log(o, 'becomes', n); });
1690
+ */
1691
+ watch: function (/** @type {any} */ scope, /** @type {string} */ expr, /** @type {Function} */ cb) {
1692
+ var started = false;
1693
+ /** @type {any} */
1694
+ var prev;
1695
+ return effect(function () {
1696
+ var next = evalExpr(scope, expr);
1697
+ if (!started) { started = true; prev = next; return; }
1698
+ if (next !== prev) {
1699
+ var o = prev;
1700
+ prev = next;
1701
+ cb(next, o);
1702
+ }
1703
+ });
1704
+ },
1705
+ /**
1706
+ * Persist a named store to localStorage (debounced) and restore it
1707
+ * on next boot. Version mismatch reseeds from init. No-op without
1708
+ * localStorage (file:// included where available).
1709
+ * @example
1710
+ * Laghu.store('cart', { n: 0 });
1711
+ * Laghu.persist('cart', { key: 'cart:v1', version: 1 });
1712
+ */
1713
+ persist: function (/** @type {string} */ name, /** @type {{key?: string, version?: number, debounce?: number}} */ opts) {
1714
+ opts = opts || {};
1715
+ var key = opts.key || ('laghu:' + name);
1716
+ var ver = opts.version === undefined ? 1 : opts.version;
1717
+ var wait = opts.debounce === undefined ? 300 : opts.debounce;
1718
+ var st = stores.get(name);
1719
+ if (!st) return null;
1720
+ try {
1721
+ var raw = typeof localStorage !== 'undefined' ? localStorage.getItem(key) : null;
1722
+ if (raw) {
1723
+ var saved = JSON.parse(raw);
1724
+ if (saved && saved.v === ver && saved.state && typeof saved.state === 'object') {
1725
+ Object.assign(st, saved.state);
1726
+ }
1727
+ }
1728
+ } catch (e) { reportError(e); }
1729
+ var timer = 0;
1730
+ effect(function () {
1731
+ JSON.stringify(st);
1732
+ if (typeof localStorage === 'undefined') return;
1733
+ if (timer) clearTimeout(timer);
1734
+ timer = /** @type {any} */ (setTimeout(function () {
1735
+ try { localStorage.setItem(key, JSON.stringify({ v: ver, state: st })); }
1736
+ catch (e) { reportError(e); }
1737
+ }, wait));
1738
+ });
1739
+ return st;
1740
+ },
1251
1741
  directive: function (/** @type {string} */ name, /** @type {Function} */ fn) { directives.set(name, fn); }
1252
1742
  };
1253
1743
  // @ts-ignore