@lmjs/core 2.5.3 → 2.5.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lmjs/core",
3
- "version": "2.5.3",
3
+ "version": "2.5.5",
4
4
  "description": "LumenJS reactive core runtime — the reactive engine, HTML/JS AST compiler, realtime socket client, and delegated event listeners powering LumenJS applications.",
5
5
  "files": [
6
6
  "dist/",
package/src/_re.js CHANGED
@@ -668,6 +668,14 @@ function _evalEventScopedExpr(ownerRe, expr, locals) {
668
668
  }
669
669
  return undefined;
670
670
  }
671
+ // `ev` and `el` are the handler's documented locals. The others evalEvAttr passes (`n`, the event's name, and
672
+ // `arg1`..`arg3`) are internal, so a page or loop variable with that name wins: `:for="names as n"` with
673
+ // `@click="n = n + '!'"` used to read and write the event name instead of the row's `n` (2026-10-08).
674
+ function isLocal(key) {
675
+ if (!locals.hasOwnProperty(key)) return false;
676
+ if (key === 'ev' || key === 'el' || key.indexOf('__lm') === 0 || !hasOwner) return true;
677
+ return !(levelOf(key) || ownFn(key));
678
+ }
671
679
  let scopeProxy = new Proxy({}, {
672
680
  has(target, key) {
673
681
  if (typeof key === 'symbol') return false;
@@ -677,7 +685,7 @@ function _evalEventScopedExpr(ownerRe, expr, locals) {
677
685
  return !!levelOf(key) || widgetsVars.hasOwnProperty(key) || _vt.Global.vars.hasOwnProperty(key);
678
686
  },
679
687
  get(target, key) {
680
- if (locals.hasOwnProperty(key)) return locals[key];
688
+ if (isLocal(key)) return locals[key];
681
689
  const f = ownFn(key);
682
690
  if (f) return f;
683
691
  const lv = levelOf(key);
@@ -688,7 +696,7 @@ function _evalEventScopedExpr(ownerRe, expr, locals) {
688
696
  return _vt.Global.vars[key];
689
697
  },
690
698
  set(target, key, value) {
691
- if (locals.hasOwnProperty(key)) { locals[key] = value; return true; }
699
+ if (isLocal(key)) { locals[key] = value; return true; }
692
700
  try {
693
701
  const lv = levelOf(key);
694
702
  if (lv) {
@@ -711,7 +719,112 @@ function _evalEventScopedExpr(ownerRe, expr, locals) {
711
719
  function _runWithScope(expr) {
712
720
  with (scopeProxy) { return eval(expr); }
713
721
  }
714
- return _runWithScope(expr);
722
+ if (locals.__lmNoWriteBack) return _runWithScope(expr);
723
+ let rows = _lmRowSnapshots(levels);
724
+ let result = _runWithScope(expr);
725
+ if (rows.length) {
726
+ _lmRowWriteBack(rows);
727
+ // `@click="save(t)"` with an async save(): changes made after its first await are saved when it settles.
728
+ if (result && typeof result.then === 'function') result.then(function () { _lmRowWriteBack(rows); }, function () { _lmRowWriteBack(rows); });
729
+ }
730
+ return result;
731
+ }
732
+
733
+ // 2026-10-08: a :for row's variables are copies of the list's items (the loop renders from a clone, which its
734
+ // diff needs). `@click="t.done = !t.done"`, `@click="toggle(t)"` or, in a bare `:for="todos"`, `done = !done` used
735
+ // to change only that copy, so nothing happened. V1 behaved the same; V2 now saves the change into the real item,
736
+ // as a Vue developer would expect: after a row's handler runs, each property of the row's item (alias, or a spread
737
+ // key) that changed is written to `<list>[index].<prop>` in the scope that owns the list, which re-renders it.
738
+ // Only changed properties are written, so values a copy can't carry (Dates, class instances) stay intact. A list
739
+ // that isn't a plain variable path (`todos.filter(...)`, a number) can't be written to; a dev-mode warning says so.
740
+ var _lmRowPathRe = /^\s*[A-Za-z_$][\w$]*(?:\s*\.\s*[A-Za-z_$][\w$]*|\[\s*\d+\s*\])*\s*$/;
741
+ var _lmRowWarned = {};
742
+ function _lmRowJson(v) {
743
+ try { return JSON.stringify(v === undefined ? null : v); } catch (e) { return null; }
744
+ }
745
+ function _lmRowSnapshots(levels) {
746
+ let rows = [];
747
+ for (let i = 0; i < levels.length; i++) {
748
+ let re = levels[i].re, info = re && re._lmRow;
749
+ if (!info) continue;
750
+ let vars = levels[i].vars, forX = info.forX || {}, as = forX.as || {};
751
+ let names = [];
752
+ if (info.isObj) {
753
+ let vName = as.v ? as.v : (as.k ? '' : 'value');
754
+ if (vName) names.push({ name: vName, whole: true });
755
+ } else {
756
+ if (as.k) names.push({ name: as.k, whole: true });
757
+ // The item's own keys are spread into the row's scope as well.
758
+ let item = as.k ? vars[as.k] : null;
759
+ let src = item && typeof item === 'object' && !Array.isArray(item) ? item : null;
760
+ for (let k in vars) {
761
+ if (!Object.prototype.hasOwnProperty.call(vars, k) || k === as.k || k === forX.dx || k === 'index' || k === 'key' || k === 'value') continue;
762
+ if (src ? Object.prototype.hasOwnProperty.call(src, k) : !as.k) names.push({ name: k, whole: false });
763
+ }
764
+ }
765
+ let snap = {};
766
+ for (let n = 0; n < names.length; n++) snap[names[n].name] = _lmRowJson(vars[names[n].name]);
767
+ rows.push({ re: re, vars: vars, info: info, names: names, snap: snap });
768
+ }
769
+ return rows;
770
+ }
771
+ function _lmRowWriteBack(rows) {
772
+ for (let r = 0; r < rows.length; r++) {
773
+ let row = rows[r], vars = row.vars, info = row.info, js = info.forX && info.forX.js;
774
+ let owner = row.re.view && row.re.view._pv;
775
+ let pos = info.isObj ? vars.key : vars.index;
776
+ for (let n = 0; n < row.names.length; n++) {
777
+ let name = row.names[n].name, now = _lmRowJson(vars[name]);
778
+ if (now === row.snap[name]) continue;
779
+ let before = row.snap[name];
780
+ row.snap[name] = now;
781
+ if (!owner || typeof js !== 'string' || !_lmRowPathRe.test(js) || pos === undefined) {
782
+ if (_lumenDevMode && !_lmRowWarned[js]) {
783
+ _lmRowWarned[js] = true;
784
+ console.warn('[LumenJS] A change to a row of :for="' + js + '" was not saved: the list is not a variable. ' +
785
+ 'Loop over a variable, or change the item in the list itself (list[i].prop = value).');
786
+ }
787
+ continue;
788
+ }
789
+ let value = vars[name];
790
+ try {
791
+ if (!row.names[n].whole) {
792
+ _evalEventScopedExpr(owner, '(' + js + ')[__lmPos][__lmProp] = __lmVal', { __lmPos: pos, __lmProp: name, __lmVal: clone(value), __lmNoWriteBack: true });
793
+ } else if (value && typeof value === 'object' && !Array.isArray(value)) {
794
+ let old = null;
795
+ try { old = JSON.parse(before); } catch (e) { old = null; }
796
+ if (!old || typeof old !== 'object' || Array.isArray(old)) {
797
+ _evalEventScopedExpr(owner, '(' + js + ')[__lmPos] = __lmVal', { __lmPos: pos, __lmVal: clone(value), __lmNoWriteBack: true });
798
+ continue;
799
+ }
800
+ for (let k in value) {
801
+ if (!Object.prototype.hasOwnProperty.call(value, k)) continue;
802
+ if (_lmRowJson(value[k]) === _lmRowJson(old[k])) continue;
803
+ _evalEventScopedExpr(owner, '(' + js + ')[__lmPos][__lmProp] = __lmVal', { __lmPos: pos, __lmProp: k, __lmVal: clone(value[k]), __lmNoWriteBack: true });
804
+ }
805
+ for (let k in old) {
806
+ if (Object.prototype.hasOwnProperty.call(old, k) && !Object.prototype.hasOwnProperty.call(value, k))
807
+ _evalEventScopedExpr(owner, 'delete (' + js + ')[__lmPos][__lmProp]', { __lmPos: pos, __lmProp: k, __lmNoWriteBack: true });
808
+ }
809
+ } else {
810
+ _evalEventScopedExpr(owner, '(' + js + ')[__lmPos] = __lmVal', { __lmPos: pos, __lmVal: clone(value), __lmNoWriteBack: true });
811
+ }
812
+ _lmRowTouch(owner, js);
813
+ } catch (e) { cl(e); }
814
+ }
815
+ }
816
+ }
817
+ // A nested write is re-rendered by the reactive proxy when the list lives in a view; a list held by an enclosing
818
+ // :for row (a nested loop) is a plain copy, so that row is told to update.
819
+ function _lmRowTouch(owner, js) {
820
+ let root = /^\s*([A-Za-z_$][\w$]*)/.exec(js);
821
+ if (!root) return;
822
+ root = root[1];
823
+ for (let e = owner, n = 0; e && e.view && n < 50; e = e.view._pv, n++) {
824
+ if (!e.view.vars || !Object.prototype.hasOwnProperty.call(e.view.vars, root)) continue;
825
+ if (e._lmRow) { try { e.update(root); } catch (err) { cl(err); } }
826
+ return;
827
+ }
715
828
  }
716
829
 
717
830
  // concatVarsAtLevel() below merges via object spread, not the loop-with-
@@ -1374,8 +1487,11 @@ class _lm {
1374
1487
  if (myLimit == limit * 1) break;
1375
1488
  try {
1376
1489
  let vx = {}; //clone(_vt.View.vars);
1377
- vx['index'] = myLimit;
1378
- if (forX['dx'] != "") vx[forX['dx']] = myLimit;
1490
+ // 2026-10-08: the row index is the item's position in the array (plus :for-offset), as in
1491
+ // V1 — not its position among the rows shown. With :for-if / :for + :if filtering rows out,
1492
+ // `items[i]` in a handler used to address a different item than the row clicked.
1493
+ vx['index'] = offset * 1 + index * 1;
1494
+ if (forX['dx'] != "") vx[forX['dx']] = offset * 1 + index * 1;
1379
1495
  if (isObj) {
1380
1496
  if (forX['as']['v'] != "") {
1381
1497
  if (forX['as']['k']) vx[forX['as']['k']] = marray[index]['key'];
@@ -1553,7 +1669,7 @@ class _lm {
1553
1669
  if (addActions.length) {
1554
1670
  let createdSections = await Promise.all(addActions.map((actn) => {
1555
1671
  let vx = arrayToRenderVXs[actn.index];
1556
- return this.createSection(cx, vx, isObj, forX);
1672
+ return this.createSection(cx, vx, isObj, forX, actn.index);
1557
1673
  }));
1558
1674
  for (let ci = 0; ci < createdSections.length; ci++) {
1559
1675
  let cln = createdSections[ci];
@@ -2214,7 +2330,7 @@ class _lm {
2214
2330
  // exists for the first time (unlike :for/:if's before-render,
2215
2331
  // which runs against DOM that's already connected).
2216
2332
  if (events && events['@after-render'] && !(attrs && (attrs.hasOwnProperty(':for') || attrs.hasOwnProperty(':if') || attrs.hasOwnProperty(':else-if') || attrs.hasOwnProperty(':else')))) {
2217
- fireRenderHook({ doc: doc }, 'after-render', _el, {});
2333
+ _queueMountHook(doc, _el);
2218
2334
  }
2219
2335
 
2220
2336
  // 2026-09-16: V1's real automatic plugin-init pass, ported
@@ -2774,8 +2890,10 @@ class _lm {
2774
2890
  });
2775
2891
  }
2776
2892
 
2777
- async createSection(cx, vx, isObj, forX) {
2778
- var keyed = cx.key + '_' + vx.index;
2893
+ async createSection(cx, vx, isObj, forX, pos) {
2894
+ // A row's DOM key is its position among the rows rendered (what the remove/update actions use), which differs
2895
+ // from vx.index (the item's array position) when :for-if / :for-offset skip items.
2896
+ var keyed = cx.key + '_' + (pos !== undefined ? pos : vx.index);
2779
2897
  // cl("DD", cx, vx, keyed);
2780
2898
  var tx = document.createTextNode("");
2781
2899
  var typeN = null;
@@ -2821,6 +2939,8 @@ class _lm {
2821
2939
  // The row element was created by the loop's owner (above); its @-handlers must resolve against THIS row's
2822
2940
  // engine (row alias, index) and fall back to the enclosing view through the engine chain.
2823
2941
  if (el && scsc) Object.defineProperty(el, '_ownerRe', { value: scsc, enumerable: false, configurable: true, writable: true });
2942
+ // Which list this row comes from, so a handler's change to the row's copy is saved into it (_lmRowWriteBack).
2943
+ if (scsc && typeN === 'for' && forX) scsc._lmRow = { forX: forX, isObj: isObj };
2824
2944
  // cl(23);
2825
2945
  // cl(scsc);
2826
2946
  scsc._re = scsc;
@@ -3055,6 +3175,44 @@ async function fireRenderHook(cx, n, containerEl, extra) {
3055
3175
  return result;
3056
3176
  }
3057
3177
 
3178
+ // 2026-10-08: a mount `@after-render` used to fire inside createEl(), while the element was still detached and
3179
+ // before the view's own <script> had run, so `@after-render="setup"` found no `setup` and was silently skipped (and
3180
+ // `el.offsetWidth`, a chart or a slider saw a detached node). It now fires once the element is in the document and,
3181
+ // for a plain function name, once that function exists: checked on the next tasks, for up to ~2s.
3182
+ var _pendingMountHooks = [];
3183
+ var _mountHookTimer = null;
3184
+ function _mountHookReady(doc, el) {
3185
+ if (!el.isConnected) return false;
3186
+ let v = doc && doc.evs && doc.evs['@after-render'];
3187
+ if (typeof v !== 'string' || !/^\s*[A-Za-z_$][\w$]*\s*$/.test(v)) return true;
3188
+ v = v.trim();
3189
+ if (typeof window[v] === 'function') return true;
3190
+ for (let e = el._ownerRe, n = 0; e && e.view && n < 50; e = e.view._pv, n++) {
3191
+ if (e.view.fns && typeof e.view.fns[v] === 'function') return true;
3192
+ }
3193
+ return false;
3194
+ }
3195
+ function _queueMountHook(doc, el) {
3196
+ _pendingMountHooks.push([doc, el, 0]);
3197
+ if (!_mountHookTimer) _mountHookTimer = setTimeout(_flushMountHooks, 0);
3198
+ }
3199
+ function _flushMountHooks() {
3200
+ _mountHookTimer = null;
3201
+ let waiting = [];
3202
+ let due = _pendingMountHooks;
3203
+ _pendingMountHooks = [];
3204
+ for (let i = 0; i < due.length; i++) {
3205
+ let doc = due[i][0], el = due[i][1], tries = due[i][2];
3206
+ if (_mountHookReady(doc, el)) fireRenderHook({ doc: doc }, 'after-render', el, {});
3207
+ else if (tries < 40) waiting.push([doc, el, tries + 1]);
3208
+ // Still not ready after ~2s: connected means the name is not a function (fire, so the usual error shows);
3209
+ // detached means the element was thrown away before it was ever shown.
3210
+ else if (el.isConnected) fireRenderHook({ doc: doc }, 'after-render', el, {});
3211
+ }
3212
+ for (let i = 0; i < waiting.length; i++) _pendingMountHooks.push(waiting[i]);
3213
+ if (_pendingMountHooks.length && !_mountHookTimer) _mountHookTimer = setTimeout(_flushMountHooks, waiting.length === _pendingMountHooks.length ? 50 : 0);
3214
+ }
3215
+
3058
3216
  // 2026-09-16: V1's real automatic plugin-init pass — [sl] (Select2),
3059
3217
  // [color] (bootstrap-colorpicker), [time]/[date]/[datetime]
3060
3218
  // (bootstrap-datetimepicker via dtp()) — ported faithfully from bea.js's
@@ -3504,7 +3662,7 @@ async function renderView(n, isSub, d, type = 'views', viewsArr, scopeBase = ["V
3504
3662
  let fileKey = btoa(filePath);
3505
3663
  if (!isSub) {
3506
3664
  View.props = d ?? {};
3507
- var _queryParams = window.location.href.split("?");
3665
+ var _queryParams = window.location.href.split("#")[0].split("?");
3508
3666
  var nn = _queryParams.shift();
3509
3667
  View.params = paraToObj(_queryParams) ?? {};
3510
3668
  }
package/src/lstnrs.js CHANGED
@@ -1221,10 +1221,14 @@ $("body").on("click touch", "a,area", function (e) {
1221
1221
  // alert(url);
1222
1222
 
1223
1223
  if (local && !cntrlon) {
1224
+ // 2026-10-08: a link to a #fragment of the page already showing
1225
+ // (`href="#faq"`, or `/guide#faq` while on /guide) is left to the
1226
+ // browser: it scrolls, and the view is not re-rendered.
1227
+ if (l.hash && l.pathname == location.pathname && l.search == location.search) return;
1224
1228
  e.preventDefault();
1225
1229
  if (el.hasAttr("stop")) e.stopPropagation();
1226
1230
 
1227
- pushURL(url + (l.search != "" ? l.search : "") ?? "home");
1231
+ pushURL(url + (l.search != "" ? l.search : "") + (l.hash || "") ?? "home");
1228
1232
  } else {
1229
1233
  }
1230
1234
  }
@@ -552,6 +552,30 @@ var miniTB = [
552
552
  // })();
553
553
 
554
554
  var XHRs = new Array();
555
+ // 2026-10-08: where web storage is blocked (a sandboxed iframe such as lumenjs.com's playground or an embed,
556
+ // cookies disabled, some privacy modes) reading `localStorage` throws, and the line below used to stop the whole
557
+ // runtime before anything rendered. Fall back to in-memory storage for that page load instead: `globals` and the
558
+ // app's own storage calls keep working, they just aren't kept after a reload.
559
+ (function () {
560
+ function memoryStorage() {
561
+ var d = {};
562
+ return {
563
+ getItem: function (k) { return Object.prototype.hasOwnProperty.call(d, k) ? d[k] : null; },
564
+ setItem: function (k, v) { d[k] = String(v); },
565
+ removeItem: function (k) { delete d[k]; },
566
+ clear: function () { d = {}; },
567
+ key: function (i) { return Object.keys(d)[i] || null; },
568
+ get length() { return Object.keys(d).length; }
569
+ };
570
+ }
571
+ ["localStorage", "sessionStorage"].forEach(function (name) {
572
+ try {
573
+ var s = window[name];
574
+ if (s) { s.getItem("globals"); return; }
575
+ } catch (e) { }
576
+ try { Object.defineProperty(window, name, { value: memoryStorage(), configurable: true }); } catch (e) { }
577
+ });
578
+ })();
555
579
  if (localStorage.getItem("globals") === null)
556
580
  localStorage.setItem("globals", JSON.stringify({}));
557
581
  if (localStorage.getItem("_rx") === null)
@@ -1294,8 +1318,31 @@ function getFormData(form) {
1294
1318
  return indexed_array;
1295
1319
  }
1296
1320
 
1321
+ // 2026-10-08: a URL's #fragment is not part of the route. `/page#part` renders
1322
+ // `page`, and once it has rendered the browser is asked to scroll to `#part`.
1323
+ // A popstate that changes only the fragment (an in-page anchor, or Back to
1324
+ // one) leaves the view alone, as a normal web page would, instead of
1325
+ // re-rendering it from scratch. V1 read the fragment as part of the view name.
1326
+ var _lumenRouteKey = null;
1327
+ function _lumenScrollToHash() {
1328
+ var h = location.hash;
1329
+ if (!h || h.length < 2) return;
1330
+ var id;
1331
+ try { id = decodeURIComponent(h.slice(1)); } catch (e) { id = h.slice(1); }
1332
+ var tries = 0;
1333
+ (function go() {
1334
+ var t = document.getElementById(id) || document.getElementsByName(id)[0];
1335
+ if (t) t.scrollIntoView();
1336
+ else if (++tries < 40) setTimeout(go, 50);
1337
+ })();
1338
+ }
1297
1339
  $(window).on("popstate", function () {
1298
- if (history && history.pushState) goToNode();
1340
+ if (!(history && history.pushState)) return;
1341
+ if (_lumenRouteKey !== null && location.pathname + location.search === _lumenRouteKey) {
1342
+ _lumenScrollToHash();
1343
+ return;
1344
+ }
1345
+ goToNode();
1299
1346
  });
1300
1347
 
1301
1348
  var getLocation = function (href) {
@@ -1325,12 +1372,14 @@ function getURLNodes() {
1325
1372
  .split(appSettings.Base)
1326
1373
  .join("")
1327
1374
  .replace(new RegExp("^[" + "/" + "]+"), "")
1375
+ .split("#")[0]
1328
1376
  .split("?")[0]
1329
1377
  .split("/");
1330
1378
 
1331
1379
  // let authFunc = (appSettings.auth && typeof appSettings.auth === "function") ? appSettings.auth : (typeof Auth === "function" ? Auth : null);
1332
1380
  // if (typeof authFunc === 'function') authFunc();
1333
1381
  if (!_nodes[0]) _nodes[0] = appSettings.defaultView ?? "home";
1382
+ _lumenRouteKey = location.pathname + location.search;
1334
1383
 
1335
1384
  if (historyStack[historyStack.length - 1]?.url != window.location.href)
1336
1385
  historyStack.push({
@@ -1346,7 +1395,7 @@ function goToNode(d) {
1346
1395
  if (_initialized) {
1347
1396
  prevNodes = clone(nodes);
1348
1397
  nodes = getURLNodes();
1349
- renderView(nodes[0], null, d);
1398
+ Promise.resolve(renderView(nodes[0], null, d)).then(_lumenScrollToHash);
1350
1399
  } else {
1351
1400
  setTimeout(function () {
1352
1401
  goToNode(d);