tova 0.3.4 → 0.3.6

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.
@@ -36,11 +36,35 @@ function flush() {
36
36
  pendingEffects.clear();
37
37
  break;
38
38
  }
39
- const toRun = [...pendingEffects];
40
- pendingEffects.clear();
41
- for (const effect of toRun) {
42
- if (!effect._disposed) {
43
- effect();
39
+
40
+ // Invoke onBeforeUpdate callbacks for owners that have pending effects
41
+ const ownersNotified = new Set();
42
+ for (const effect of pendingEffects) {
43
+ const owner = effect._owner;
44
+ if (owner && owner._beforeUpdate && !ownersNotified.has(owner)) {
45
+ ownersNotified.add(owner);
46
+ for (const cb of owner._beforeUpdate) {
47
+ try { cb(); } catch (e) { console.error('Tova: onBeforeUpdate error:', e); }
48
+ }
49
+ }
50
+ }
51
+
52
+ const toRun = pendingEffects;
53
+ pendingEffects = new Set();
54
+ // Sort by depth (parents first) to avoid redundant child re-runs
55
+ if (toRun.size > 1) {
56
+ const sorted = Array.from(toRun);
57
+ sorted.sort((a, b) => (a._depth || 0) - (b._depth || 0));
58
+ for (const effect of sorted) {
59
+ if (!effect._disposed) {
60
+ effect();
61
+ }
62
+ }
63
+ } else {
64
+ for (const effect of toRun) {
65
+ if (!effect._disposed) {
66
+ effect();
67
+ }
44
68
  }
45
69
  }
46
70
  }
@@ -73,10 +97,10 @@ export function createRoot(fn) {
73
97
  dispose() {
74
98
  if (root._disposed) return;
75
99
  root._disposed = true;
76
- // Dispose children in reverse order
100
+ // Dispose children in reverse order (skip already-disposed)
77
101
  for (let i = root._children.length - 1; i >= 0; i--) {
78
102
  const child = root._children[i];
79
- if (typeof child.dispose === 'function') child.dispose();
103
+ if (!child._disposed && typeof child.dispose === 'function') child.dispose();
80
104
  }
81
105
  root._children.length = 0;
82
106
  // Run cleanups in reverse order
@@ -144,7 +168,7 @@ export function createSignal(initialValue, name) {
144
168
  if (__devtools_hooks && signalId != null) {
145
169
  __devtools_hooks.onSignalUpdate(signalId, oldValue, newValue);
146
170
  }
147
- for (const sub of [...subscribers]) {
171
+ for (const sub of subscribers) {
148
172
  if (sub._isComputed) {
149
173
  sub(); // propagate dirty flags synchronously through computed graph
150
174
  } else {
@@ -218,6 +242,8 @@ export function createEffect(fn) {
218
242
  effect._cleanup = null;
219
243
  effect._cleanups = [];
220
244
  effect._owner = currentOwner;
245
+ // Compute depth for priority scheduling (parents flush before children)
246
+ effect._depth = currentOwner ? (currentOwner._depth || 0) + 1 : 0;
221
247
 
222
248
  if (__devtools_hooks) {
223
249
  __devtools_hooks.onEffectCreate(effect);
@@ -232,11 +258,6 @@ export function createEffect(fn) {
232
258
  runCleanups(effect);
233
259
  cleanupDeps(effect);
234
260
  pendingEffects.delete(effect);
235
- // Remove from owner's children
236
- if (effect._owner) {
237
- const idx = effect._owner._children.indexOf(effect);
238
- if (idx >= 0) effect._owner._children.splice(idx, 1);
239
- }
240
261
  };
241
262
 
242
263
  // Run immediately (synchronous first run)
@@ -256,9 +277,10 @@ export function createComputed(fn) {
256
277
  function notify() {
257
278
  if (!dirty) {
258
279
  dirty = true;
259
- for (const sub of [...subscribers]) {
280
+ notify._dirty = true;
281
+ for (const sub of subscribers) {
260
282
  if (sub._isComputed) {
261
- sub(); // cascade dirty flags synchronously
283
+ if (!sub._dirty) sub(); // skip already-dirty computeds
262
284
  } else {
263
285
  pendingEffects.add(sub);
264
286
  }
@@ -278,10 +300,6 @@ export function createComputed(fn) {
278
300
  notify.dispose = function () {
279
301
  notify._disposed = true;
280
302
  cleanupDeps(notify);
281
- if (notify._owner) {
282
- const idx = notify._owner._children.indexOf(notify);
283
- if (idx >= 0) notify._owner._children.splice(idx, 1);
284
- }
285
303
  };
286
304
 
287
305
  function recompute() {
@@ -292,6 +310,7 @@ export function createComputed(fn) {
292
310
  try {
293
311
  value = fn();
294
312
  dirty = false;
313
+ notify._dirty = false;
295
314
  } finally {
296
315
  effectStack.pop();
297
316
  currentEffect = effectStack[effectStack.length - 1] || null;
@@ -339,6 +358,13 @@ export function onCleanup(fn) {
339
358
  }
340
359
  }
341
360
 
361
+ export function onBeforeUpdate(fn) {
362
+ if (currentOwner && !currentOwner._disposed) {
363
+ if (!currentOwner._beforeUpdate) currentOwner._beforeUpdate = [];
364
+ currentOwner._beforeUpdate.push(fn);
365
+ }
366
+ }
367
+
342
368
  // ─── Untrack ─────────────────────────────────────────────
343
369
  // Run a function without tracking any signal reads (opt out of reactivity)
344
370
 
@@ -610,6 +636,40 @@ export function Portal({ target, children }) {
610
636
  };
611
637
  }
612
638
 
639
+ // ─── Suspense ────────────────────────────────────────────
640
+ // Renders fallback while any child lazy() component is loading.
641
+ // Usage: Suspense({ fallback: loadingEl, children: [LazyComp(props)] })
642
+
643
+ const SuspenseContext = createContext(null);
644
+
645
+ export function Suspense({ fallback, children }) {
646
+ const [pending, setPending] = createSignal(0);
647
+ const childContent = children && children.length === 1 ? children[0] : tova_fragment(children || []);
648
+
649
+ const boundary = {
650
+ register() {
651
+ setPending(p => p + 1);
652
+ },
653
+ resolve() {
654
+ setPending(p => Math.max(0, p - 1));
655
+ },
656
+ };
657
+
658
+ return {
659
+ __tova: true,
660
+ tag: '__dynamic',
661
+ props: {},
662
+ children: [],
663
+ compute: () => {
664
+ provide(SuspenseContext, boundary);
665
+ if (pending() > 0) {
666
+ return typeof fallback === 'function' ? fallback() : fallback;
667
+ }
668
+ return childContent;
669
+ },
670
+ };
671
+ }
672
+
613
673
  // ─── Lazy ───────────────────────────────────────────────
614
674
  // Async component loading with optional fallback.
615
675
  // Usage: const LazyComp = lazy(() => import('./HeavyComponent.js'))
@@ -624,12 +684,20 @@ export function lazy(loader) {
624
684
  return resolved(props);
625
685
  }
626
686
 
687
+ // Check for Suspense boundary
688
+ const suspense = inject(SuspenseContext);
689
+
627
690
  if (!promise) {
691
+ if (suspense) suspense.register();
628
692
  promise = loader()
629
693
  .then(mod => {
630
694
  resolved = mod.default || mod;
695
+ if (suspense) suspense.resolve();
631
696
  })
632
- .catch(e => { loadError = e; });
697
+ .catch(e => {
698
+ loadError = e;
699
+ if (suspense) suspense.resolve();
700
+ });
633
701
  }
634
702
 
635
703
  const [tick, setTick] = createSignal(0);
@@ -646,7 +714,7 @@ export function lazy(loader) {
646
714
  tick(); // Track for reactivity
647
715
  if (loadError) return tova_el('span', { className: 'tova-error' }, [String(loadError)]);
648
716
  if (resolved) return resolved(props);
649
- // Fallback while loading
717
+ // Fallback while loading (individual or Suspense-level)
650
718
  return props && props.fallback ? props.fallback : null;
651
719
  },
652
720
  };
@@ -754,34 +822,92 @@ function getTransitionCSS(name, config, phase) {
754
822
  }
755
823
  }
756
824
 
757
- export function tova_transition(vnode, name, config = {}) {
825
+ export function tova_transition(vnode, nameOrConfig, config = {}) {
826
+ if (!vnode || !vnode.__tova) return vnode;
827
+
828
+ // Directional transitions: tova_transition(vnode, { in: {...}, out: {...} })
829
+ if (typeof nameOrConfig === 'object' && nameOrConfig !== null && !nameOrConfig.__tova && (nameOrConfig.in || nameOrConfig.out)) {
830
+ vnode._transition = { directional: true, in: nameOrConfig.in, out: nameOrConfig.out };
831
+ return vnode;
832
+ }
833
+
834
+ // Custom transition function: tova_transition(vnode, myTransitionFn, config)
835
+ if (typeof nameOrConfig === 'function') {
836
+ vnode._transition = { custom: nameOrConfig, config };
837
+ return vnode;
838
+ }
839
+
840
+ // Built-in transition: tova_transition(vnode, "fade", config)
841
+ vnode._transition = { name: nameOrConfig, config };
842
+ return vnode;
843
+ }
844
+
845
+ // ─── Actions ──────────────────────────────────────────────
846
+ // use: directive support. Calls actionFn(el, param) after render.
847
+ // Returns the wrapped vnode. The action lifecycle (update/destroy) is managed.
848
+
849
+ export function __tova_action(vnode, actionFn, param) {
758
850
  if (!vnode || !vnode.__tova) return vnode;
759
- vnode._transition = { name, config };
851
+ if (!vnode._actions) vnode._actions = [];
852
+ vnode._actions.push({ fn: actionFn, param });
760
853
  return vnode;
761
854
  }
762
855
 
763
856
  // Apply enter transition to a DOM element after render
764
857
  function applyEnterTransition(el, trans) {
765
858
  if (!trans) return;
766
- const fromStyles = getTransitionCSS(trans.name, trans.config, 'enter-from');
767
- const toStyles = getTransitionCSS(trans.name, trans.config, 'enter-to');
859
+
860
+ // Custom transition function
861
+ if (trans.custom) {
862
+ const result = trans.custom(el, trans.config || {}, 'enter');
863
+ if (result && typeof result === 'object' && !result.then) {
864
+ Object.assign(el.style, result);
865
+ }
866
+ return;
867
+ }
868
+
869
+ // Directional: use 'in' config for enter
870
+ const name = trans.directional ? (trans.in ? trans.in.name : null) : trans.name;
871
+ const config = trans.directional ? (trans.in ? trans.in.config : {}) : trans.config;
872
+ if (!name) return;
873
+
874
+ const fromStyles = getTransitionCSS(name, config, 'enter-from');
875
+ const toStyles = getTransitionCSS(name, config, 'enter-to');
768
876
 
769
877
  // Set initial state
770
878
  Object.assign(el.style, fromStyles);
771
879
 
772
880
  // Force reflow, then apply target state
773
- requestAnimationFrame(() => {
774
- requestAnimationFrame(() => {
775
- Object.assign(el.style, toStyles);
776
- });
777
- });
881
+ void el.offsetHeight;
882
+ Object.assign(el.style, toStyles);
778
883
  }
779
884
 
780
885
  // Apply leave transition and return a Promise that resolves when done
781
886
  function applyLeaveTransition(el, trans) {
782
887
  if (!trans) return Promise.resolve();
783
- const duration = (trans.config && trans.config.duration) || TRANSITION_DEFAULTS[trans.name]?.duration || 200;
784
- const toStyles = getTransitionCSS(trans.name, trans.config, 'leave-to');
888
+
889
+ // Custom transition function
890
+ if (trans.custom) {
891
+ const result = trans.custom(el, trans.config || {}, 'leave');
892
+ if (result && typeof result.then === 'function') {
893
+ // Race with timeout to prevent leaked promises from custom transitions
894
+ const dur = (trans.config && trans.config.duration) || 5000;
895
+ return Promise.race([result, new Promise(r => setTimeout(r, dur + 100))]);
896
+ }
897
+ if (result && typeof result === 'object') {
898
+ Object.assign(el.style, result);
899
+ }
900
+ const dur = (trans.config && trans.config.duration) || 200;
901
+ return new Promise(resolve => setTimeout(resolve, dur));
902
+ }
903
+
904
+ // Directional: use 'out' config for leave
905
+ const name = trans.directional ? (trans.out ? trans.out.name : null) : trans.name;
906
+ const config = trans.directional ? (trans.out ? trans.out.config : {}) : trans.config;
907
+ if (!name) return Promise.resolve();
908
+
909
+ const duration = (config && config.duration) || TRANSITION_DEFAULTS[name]?.duration || 200;
910
+ const toStyles = getTransitionCSS(name, config, 'leave-to');
785
911
  Object.assign(el.style, toStyles);
786
912
 
787
913
  return new Promise(resolve => {
@@ -911,6 +1037,9 @@ function clearMarkerContent(marker) {
911
1037
  applyLeaveTransition(el, el.__tovaTransition).then(() => {
912
1038
  disposeNode(el);
913
1039
  if (el.parentNode) el.parentNode.removeChild(el);
1040
+ }).catch(() => {
1041
+ disposeNode(el);
1042
+ if (el.parentNode) el.parentNode.removeChild(el);
914
1043
  });
915
1044
  } else {
916
1045
  disposeNode(node);
@@ -1112,6 +1241,29 @@ export function render(vnode) {
1112
1241
  applyEnterTransition(el, vnode._transition);
1113
1242
  }
1114
1243
 
1244
+ // Apply use: actions if present
1245
+ if (vnode._actions && vnode._actions.length > 0) {
1246
+ for (const action of vnode._actions) {
1247
+ const paramValue = typeof action.param === 'function' ? action.param() : action.param;
1248
+ const result = action.fn(el, paramValue);
1249
+ if (result) {
1250
+ // If param is reactive, set up effect for updates
1251
+ if (typeof action.param === 'function') {
1252
+ createEffect(() => {
1253
+ const newVal = action.param();
1254
+ if (result.update) result.update(newVal);
1255
+ });
1256
+ }
1257
+ // Register destroy on cleanup
1258
+ if (result.destroy) {
1259
+ if (currentOwner && !currentOwner._disposed) {
1260
+ currentOwner._cleanups.push(result.destroy);
1261
+ }
1262
+ }
1263
+ }
1264
+ }
1265
+ }
1266
+
1115
1267
  return el;
1116
1268
  }
1117
1269
 
@@ -1126,9 +1278,17 @@ function applyReactiveProps(el, props) {
1126
1278
  }
1127
1279
  } else if (key.startsWith('on')) {
1128
1280
  const eventName = key.slice(2).toLowerCase();
1129
- el.addEventListener(eventName, value);
1130
- if (!el.__handlers) el.__handlers = {};
1131
- el.__handlers[eventName] = value;
1281
+ if (typeof value === 'object' && value !== null && value.handler) {
1282
+ el.addEventListener(eventName, value.handler, value.options);
1283
+ if (!el.__handlers) el.__handlers = {};
1284
+ el.__handlers[eventName] = value.handler;
1285
+ el.__handlerOptions = el.__handlerOptions || {};
1286
+ el.__handlerOptions[eventName] = value.options;
1287
+ } else {
1288
+ el.addEventListener(eventName, value);
1289
+ if (!el.__handlers) el.__handlers = {};
1290
+ el.__handlers[eventName] = value;
1291
+ }
1132
1292
  } else if (key === 'key') {
1133
1293
  // Skip
1134
1294
  } else if (typeof value === 'function' && !key.startsWith('on')) {
@@ -1148,6 +1308,9 @@ function applyPropValue(el, key, val) {
1148
1308
  if (el.className !== val) el.className = val || '';
1149
1309
  } else if (key === 'innerHTML' || key === 'dangerouslySetInnerHTML') {
1150
1310
  const html = typeof val === 'object' && val !== null ? val.__html || '' : val || '';
1311
+ if (__DEV__ && html) {
1312
+ console.warn('Tova: Setting innerHTML can expose your app to XSS attacks. Ensure the content is sanitized.');
1313
+ }
1151
1314
  if (el.innerHTML !== html) el.innerHTML = html;
1152
1315
  } else if (key === 'value') {
1153
1316
  if (el !== document.activeElement && el.value !== val) {
@@ -1158,14 +1321,13 @@ function applyPropValue(el, key, val) {
1158
1321
  } else if (key === 'disabled' || key === 'readOnly' || key === 'hidden') {
1159
1322
  el[key] = !!val;
1160
1323
  } else if (key === 'style' && typeof val === 'object') {
1161
- // Clear old properties not present in new style object
1162
- for (let i = el.style.length - 1; i >= 0; i--) {
1163
- const prop = el.style[i];
1164
- const camel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
1165
- if (!(prop in val) && !(camel in val)) {
1166
- el.style.removeProperty(prop);
1324
+ // Delta update: only remove properties that were in previous style but not in new
1325
+ if (el.__prevStyle) {
1326
+ for (const prop of Object.keys(el.__prevStyle)) {
1327
+ if (!(prop in val)) el.style.removeProperty(prop);
1167
1328
  }
1168
1329
  }
1330
+ el.__prevStyle = { ...val };
1169
1331
  Object.assign(el.style, val);
1170
1332
  } else {
1171
1333
  const s = val == null ? '' : String(val);
@@ -1209,12 +1371,25 @@ function applyProps(el, newProps, oldProps) {
1209
1371
  }
1210
1372
  } else if (key.startsWith('on')) {
1211
1373
  const eventName = key.slice(2).toLowerCase();
1212
- const oldHandler = el.__handlers && el.__handlers[eventName];
1213
- if (oldHandler !== value) {
1214
- if (oldHandler) el.removeEventListener(eventName, oldHandler);
1215
- el.addEventListener(eventName, value);
1216
- if (!el.__handlers) el.__handlers = {};
1217
- el.__handlers[eventName] = value;
1374
+ if (typeof value === 'object' && value !== null && value.handler) {
1375
+ const oldHandler = el.__handlers && el.__handlers[eventName];
1376
+ if (oldHandler !== value.handler) {
1377
+ const oldOpts = el.__handlerOptions && el.__handlerOptions[eventName];
1378
+ if (oldHandler) el.removeEventListener(eventName, oldHandler, oldOpts);
1379
+ el.addEventListener(eventName, value.handler, value.options);
1380
+ if (!el.__handlers) el.__handlers = {};
1381
+ el.__handlers[eventName] = value.handler;
1382
+ el.__handlerOptions = el.__handlerOptions || {};
1383
+ el.__handlerOptions[eventName] = value.options;
1384
+ }
1385
+ } else {
1386
+ const oldHandler = el.__handlers && el.__handlers[eventName];
1387
+ if (oldHandler !== value) {
1388
+ if (oldHandler) el.removeEventListener(eventName, oldHandler);
1389
+ el.addEventListener(eventName, value);
1390
+ if (!el.__handlers) el.__handlers = {};
1391
+ el.__handlers[eventName] = value;
1392
+ }
1218
1393
  }
1219
1394
  } else if (key === 'style' && typeof value === 'object') {
1220
1395
  Object.assign(el.style, value);
@@ -1236,6 +1411,51 @@ function applyProps(el, newProps, oldProps) {
1236
1411
  }
1237
1412
  }
1238
1413
 
1414
+ // ─── Longest Increasing Subsequence (O(n log n)) ────────
1415
+ // Used by keyed reconciliation to minimize DOM moves.
1416
+
1417
+ function longestIncreasingSubsequence(arr) {
1418
+ const n = arr.length;
1419
+ if (n === 0) return [];
1420
+
1421
+ // tails[i] = index in arr of smallest tail element for IS of length i+1
1422
+ const tails = [];
1423
+ // parent[i] = index in arr of predecessor of arr[i] in the LIS
1424
+ const parent = new Array(n).fill(-1);
1425
+ // indices[i] = index in arr of tails[i]
1426
+ const indices = [];
1427
+
1428
+ for (let i = 0; i < n; i++) {
1429
+ const val = arr[i];
1430
+ if (val < 0) continue; // skip removed items (marker -1)
1431
+
1432
+ // Binary search for the insertion point
1433
+ let lo = 0, hi = tails.length;
1434
+ while (lo < hi) {
1435
+ const mid = (lo + hi) >> 1;
1436
+ if (tails[mid] < val) lo = mid + 1;
1437
+ else hi = mid;
1438
+ }
1439
+
1440
+ tails[lo] = val;
1441
+ indices[lo] = i;
1442
+
1443
+ if (lo > 0) {
1444
+ parent[i] = indices[lo - 1];
1445
+ }
1446
+ }
1447
+
1448
+ // Reconstruct
1449
+ const result = new Array(tails.length);
1450
+ let k = indices[tails.length - 1];
1451
+ for (let i = tails.length - 1; i >= 0; i--) {
1452
+ result[i] = k;
1453
+ k = parent[k];
1454
+ }
1455
+
1456
+ return result;
1457
+ }
1458
+
1239
1459
  // ─── Keyed Reconciliation ────────────────────────────────
1240
1460
 
1241
1461
  function getKey(vnode) {
@@ -1311,10 +1531,19 @@ function patchKeyedInMarker(marker, newVNodes) {
1311
1531
  }
1312
1532
  }
1313
1533
 
1314
- // Arrange in correct order after marker using cursor approach
1534
+ // LIS-based reorder: compute old positions, find LIS, only move non-LIS nodes
1535
+ const oldPosMap = new Map();
1536
+ for (let i = 0; i < oldNodes.length; i++) {
1537
+ oldPosMap.set(oldNodes[i], i);
1538
+ }
1539
+ const positions = newNodes.map(n => oldPosMap.has(n) ? oldPosMap.get(n) : -1);
1540
+ const lisIndices = new Set(longestIncreasingSubsequence(positions));
1541
+
1542
+ // Insert nodes: only move nodes not in the LIS
1315
1543
  let cursor = marker.nextSibling;
1316
- for (const node of newNodes) {
1317
- if (node === cursor) {
1544
+ for (let i = 0; i < newNodes.length; i++) {
1545
+ const node = newNodes[i];
1546
+ if (lisIndices.has(i) && node === cursor) {
1318
1547
  cursor = node.nextSibling;
1319
1548
  } else {
1320
1549
  parent.insertBefore(node, cursor);
@@ -1331,9 +1560,10 @@ function patchPositionalInMarker(marker, newChildren) {
1331
1560
  const oldCount = oldNodes.length;
1332
1561
  const newCount = newChildren.length;
1333
1562
 
1334
- // Patch in place
1563
+ // Patch in place (skip identical vnodes)
1335
1564
  const patchCount = Math.min(oldCount, newCount);
1336
1565
  for (let i = 0; i < patchCount; i++) {
1566
+ if (oldNodes[i] === newChildren[i]) continue;
1337
1567
  patchSingle(parent, oldNodes[i], newChildren[i]);
1338
1568
  }
1339
1569
 
@@ -1396,13 +1626,23 @@ function patchKeyedChildren(parent, newVNodes) {
1396
1626
  }
1397
1627
  }
1398
1628
 
1399
- // Arrange in correct order
1629
+ // LIS-based reorder for element children
1630
+ const logicalAfterRemove = getLogicalChildren(parent);
1631
+ const oldPosMap = new Map();
1632
+ for (let i = 0; i < logicalAfterRemove.length; i++) {
1633
+ oldPosMap.set(logicalAfterRemove[i], i);
1634
+ }
1635
+ const positions = newNodes.map(n => oldPosMap.has(n) ? oldPosMap.get(n) : -1);
1636
+ const lisIndices = new Set(longestIncreasingSubsequence(positions));
1637
+
1400
1638
  for (let i = 0; i < newNodes.length; i++) {
1401
1639
  const expected = newNodes[i];
1402
- const logicalNow = getLogicalChildren(parent);
1403
- const current = logicalNow[i];
1404
- if (current !== expected) {
1405
- parent.insertBefore(expected, current || null);
1640
+ if (!lisIndices.has(i)) {
1641
+ const logicalNow = getLogicalChildren(parent);
1642
+ const current = logicalNow[i];
1643
+ if (current !== expected) {
1644
+ parent.insertBefore(expected, current || null);
1645
+ }
1406
1646
  }
1407
1647
  }
1408
1648
  }
@@ -1713,9 +1953,17 @@ function hydrateProps(el, props) {
1713
1953
  }
1714
1954
  } else if (key.startsWith('on')) {
1715
1955
  const eventName = key.slice(2).toLowerCase();
1716
- el.addEventListener(eventName, value);
1717
- if (!el.__handlers) el.__handlers = {};
1718
- el.__handlers[eventName] = value;
1956
+ if (typeof value === 'object' && value !== null && value.handler) {
1957
+ el.addEventListener(eventName, value.handler, value.options);
1958
+ if (!el.__handlers) el.__handlers = {};
1959
+ el.__handlers[eventName] = value.handler;
1960
+ el.__handlerOptions = el.__handlerOptions || {};
1961
+ el.__handlerOptions[eventName] = value.options;
1962
+ } else {
1963
+ el.addEventListener(eventName, value);
1964
+ if (!el.__handlers) el.__handlers = {};
1965
+ el.__handlers[eventName] = value;
1966
+ }
1719
1967
  } else if (key === 'key') {
1720
1968
  // Skip
1721
1969
  } else if (typeof value === 'function' && !key.startsWith('on')) {