pulse-js-framework 1.7.2 → 1.7.3

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/runtime/dom.js CHANGED
@@ -26,7 +26,30 @@ const log = loggers.dom;
26
26
  // - 500 provides headroom for dynamic selectors without excessive memory
27
27
  //
28
28
  // Cache hit returns a shallow copy to prevent mutation of cached config
29
- const selectorCache = new LRUCache(500);
29
+ // Metrics tracking enabled for performance monitoring
30
+ const selectorCache = new LRUCache(500, { trackMetrics: true });
31
+
32
+ /**
33
+ * Get selector cache performance metrics.
34
+ * Useful for debugging and performance tuning.
35
+ *
36
+ * @returns {{hits: number, misses: number, evictions: number, hitRate: number, size: number, capacity: number}}
37
+ * @example
38
+ * const stats = getCacheMetrics();
39
+ * console.log(`Cache hit rate: ${(stats.hitRate * 100).toFixed(1)}%`);
40
+ * console.log(`Cache size: ${stats.size}/${stats.capacity}`);
41
+ */
42
+ export function getCacheMetrics() {
43
+ return selectorCache.getMetrics();
44
+ }
45
+
46
+ /**
47
+ * Reset cache metrics counters.
48
+ * Useful for measuring performance over specific time periods.
49
+ */
50
+ export function resetCacheMetrics() {
51
+ selectorCache.resetMetrics();
52
+ }
30
53
 
31
54
  /**
32
55
  * Safely insert a node before a reference node
@@ -374,6 +397,57 @@ export function on(element, event, handler, options) {
374
397
  return element;
375
398
  }
376
399
 
400
+ /**
401
+ * Compute Longest Increasing Subsequence indices
402
+ * Used to minimize DOM moves during list reconciliation
403
+ * @private
404
+ * @param {number[]} arr - Array of indices
405
+ * @returns {number[]} Indices of elements in the LIS
406
+ */
407
+ function computeLIS(arr) {
408
+ const n = arr.length;
409
+ if (n === 0) return [];
410
+
411
+ // dp[i] = smallest tail of LIS of length i+1
412
+ const dp = [];
413
+ // parent[i] = index of previous element in LIS ending at i
414
+ const parent = new Array(n).fill(-1);
415
+ // indices[i] = index in original array of dp[i]
416
+ const indices = [];
417
+
418
+ for (let i = 0; i < n; i++) {
419
+ const val = arr[i];
420
+
421
+ // Binary search for position
422
+ let lo = 0, hi = dp.length;
423
+ while (lo < hi) {
424
+ const mid = (lo + hi) >> 1;
425
+ if (dp[mid] < val) lo = mid + 1;
426
+ else hi = mid;
427
+ }
428
+
429
+ if (lo === dp.length) {
430
+ dp.push(val);
431
+ indices.push(i);
432
+ } else {
433
+ dp[lo] = val;
434
+ indices[lo] = i;
435
+ }
436
+
437
+ parent[i] = lo > 0 ? indices[lo - 1] : -1;
438
+ }
439
+
440
+ // Reconstruct LIS indices
441
+ const lis = [];
442
+ let idx = indices[dp.length - 1];
443
+ while (idx !== -1) {
444
+ lis.push(idx);
445
+ idx = parent[idx];
446
+ }
447
+
448
+ return lis.reverse();
449
+ }
450
+
377
451
  /**
378
452
  * Create a reactive list with efficient keyed diffing
379
453
  *
@@ -389,19 +463,19 @@ export function on(element, event, handler, options) {
389
463
  * a) Build a map of new items by key
390
464
  * b) For existing keys: reuse the DOM nodes (no re-creation)
391
465
  * c) For removed keys: remove DOM nodes and run cleanup
392
- * d) For new keys: create DOM nodes via template function
466
+ * d) For new keys: batch create via DocumentFragment
393
467
  *
394
- * 3. REORDERING: Uses a single forward pass:
395
- * - Tracks previous node position with prevNode cursor
396
- * - Only moves nodes that are out of position
397
- * - Nodes already in correct position are skipped (no DOM operation)
468
+ * 3. REORDERING: Uses LIS (Longest Increasing Subsequence):
469
+ * - Computes which nodes are already in correct relative order
470
+ * - Only moves nodes NOT in the LIS (minimizes DOM operations)
471
+ * - New items are batched into DocumentFragment before insertion
398
472
  *
399
473
  * 4. BOUNDARY MARKERS: Uses comment nodes to track list boundaries:
400
474
  * - startMarker: Insertion point for first item
401
475
  * - endMarker: End boundary (not currently used but reserved)
402
476
  *
403
- * COMPLEXITY: O(n) for reconciliation + O(m) DOM moves where m <= n
404
- * Best case (no changes): O(n) comparisons, 0 DOM operations
477
+ * COMPLEXITY: O(n log n) for LIS + O(m) DOM moves where m = n - LIS length
478
+ * Best case (append only): O(n) with single DocumentFragment insert
405
479
  *
406
480
  * @param {Function|Pulse} getItems - Items source (reactive)
407
481
  * @param {Function} template - (item, index) => Node | Node[]
@@ -426,8 +500,9 @@ export function list(getItems, template, keyFn = (item, i) => i) {
426
500
 
427
501
  const newKeys = [];
428
502
  const newItemNodes = new Map();
503
+ const newItems = []; // Track new items for batched insertion
429
504
 
430
- // Build map of new items by key
505
+ // Phase 1: Build map of new items by key
431
506
  itemsArray.forEach((item, index) => {
432
507
  const key = keyFn(item, index);
433
508
  newKeys.push(key);
@@ -436,14 +511,21 @@ export function list(getItems, template, keyFn = (item, i) => i) {
436
511
  // Reuse existing entry
437
512
  newItemNodes.set(key, itemNodes.get(key));
438
513
  } else {
439
- // Create new nodes
514
+ // Mark as new (will batch create later)
515
+ newItems.push({ key, item, index });
516
+ }
517
+ });
518
+
519
+ // Phase 2: Batch create new nodes using DocumentFragment
520
+ if (newItems.length > 0) {
521
+ for (const { key, item, index } of newItems) {
440
522
  const result = template(item, index);
441
523
  const nodes = Array.isArray(result) ? result : [result];
442
524
  newItemNodes.set(key, { nodes, cleanup: null, item });
443
525
  }
444
- });
526
+ }
445
527
 
446
- // Remove items that are no longer present
528
+ // Phase 3: Remove items that are no longer present
447
529
  for (const [key, entry] of itemNodes) {
448
530
  if (!newItemNodes.has(key)) {
449
531
  for (const node of entry.nodes) {
@@ -453,25 +535,101 @@ export function list(getItems, template, keyFn = (item, i) => i) {
453
535
  }
454
536
  }
455
537
 
456
- // Efficient reordering using minimal DOM operations
457
- // Use a simple diff algorithm: iterate through new order and move/insert as needed
458
- let prevNode = startMarker;
538
+ // Phase 4: Efficient reordering using LIS algorithm
539
+ // Build old position map for existing keys
540
+ const oldKeyIndex = new Map();
541
+ keyOrder.forEach((key, i) => oldKeyIndex.set(key, i));
459
542
 
543
+ // Get indices of existing items in old order
544
+ const existingIndices = [];
545
+ const existingKeys = [];
460
546
  for (let i = 0; i < newKeys.length; i++) {
461
547
  const key = newKeys[i];
462
- const entry = newItemNodes.get(key);
463
- const firstNode = entry.nodes[0];
548
+ if (oldKeyIndex.has(key)) {
549
+ existingIndices.push(oldKeyIndex.get(key));
550
+ existingKeys.push(key);
551
+ }
552
+ }
464
553
 
465
- // Check if node is already in correct position
466
- if (prevNode.nextSibling !== firstNode) {
467
- // Need to move/insert
554
+ // Compute LIS - these nodes don't need to move
555
+ const lisIndices = new Set(computeLIS(existingIndices));
556
+ const stableKeys = new Set();
557
+ existingKeys.forEach((key, i) => {
558
+ if (lisIndices.has(i)) {
559
+ stableKeys.add(key);
560
+ }
561
+ });
562
+
563
+ // Phase 5: Position nodes with minimal DOM operations
564
+ const parent = startMarker.parentNode;
565
+ if (!parent) {
566
+ // Not yet in DOM, use simple append with DocumentFragment batch
567
+ const fragment = document.createDocumentFragment();
568
+ for (const key of newKeys) {
569
+ const entry = newItemNodes.get(key);
468
570
  for (const node of entry.nodes) {
469
- prevNode.parentNode?.insertBefore(node, prevNode.nextSibling);
470
- prevNode = node;
571
+ fragment.appendChild(node);
572
+ }
573
+ }
574
+ container.insertBefore(fragment, endMarker);
575
+ } else {
576
+ // Optimized reordering: batch consecutive inserts using DocumentFragment
577
+ let prevNode = startMarker;
578
+
579
+ // Process items in new order
580
+ for (let i = 0; i < newKeys.length; i++) {
581
+ const key = newKeys[i];
582
+ const entry = newItemNodes.get(key);
583
+ const firstNode = entry.nodes[0];
584
+ const isNew = !oldKeyIndex.has(key);
585
+ const isStable = stableKeys.has(key);
586
+
587
+ // Check if node is already in correct position
588
+ const inPosition = prevNode.nextSibling === firstNode;
589
+
590
+ if (inPosition && (isStable || isNew)) {
591
+ // Already in correct position, just advance
592
+ prevNode = entry.nodes[entry.nodes.length - 1];
593
+ } else {
594
+ // Collect consecutive items that need to be inserted at this position
595
+ const fragment = document.createDocumentFragment();
596
+ let j = i;
597
+
598
+ while (j < newKeys.length) {
599
+ const k = newKeys[j];
600
+ const e = newItemNodes.get(k);
601
+ const f = e.nodes[0];
602
+ const n = !oldKeyIndex.has(k);
603
+ const s = stableKeys.has(k);
604
+
605
+ // If this item is already in position after prevNode, stop batching
606
+ if (j > i && prevNode.nextSibling === f) {
607
+ break;
608
+ }
609
+
610
+ // Add to batch if it's new or needs to move
611
+ if (n || !s || prevNode.nextSibling !== f) {
612
+ for (const node of e.nodes) {
613
+ fragment.appendChild(node);
614
+ }
615
+ j++;
616
+ } else {
617
+ break;
618
+ }
619
+ }
620
+
621
+ // Insert the batch
622
+ if (fragment.childNodes.length > 0) {
623
+ parent.insertBefore(fragment, prevNode.nextSibling);
624
+ }
625
+
626
+ // Update prevNode to last inserted node
627
+ if (j > i) {
628
+ const lastEntry = newItemNodes.get(newKeys[j - 1]);
629
+ prevNode = lastEntry.nodes[lastEntry.nodes.length - 1];
630
+ i = j - 1; // Continue from where we left off
631
+ }
471
632
  }
472
- } else {
473
- // Already in position, just advance prevNode
474
- prevNode = entry.nodes[entry.nodes.length - 1];
475
633
  }
476
634
  }
477
635
 
@@ -564,6 +722,9 @@ export function match(getValue, cases) {
564
722
 
565
723
  /**
566
724
  * Two-way binding for form inputs
725
+ *
726
+ * MEMORY SAFETY: All event listeners are registered with onCleanup()
727
+ * to prevent memory leaks when the element is removed from the DOM.
567
728
  */
568
729
  export function model(element, pulseValue) {
569
730
  const tagName = element.tagName.toLowerCase();
@@ -574,17 +735,17 @@ export function model(element, pulseValue) {
574
735
  effect(() => {
575
736
  element.checked = pulseValue.get();
576
737
  });
577
- element.addEventListener('change', () => {
578
- pulseValue.set(element.checked);
579
- });
738
+ const handler = () => pulseValue.set(element.checked);
739
+ element.addEventListener('change', handler);
740
+ onCleanup(() => element.removeEventListener('change', handler));
580
741
  } else if (tagName === 'select') {
581
742
  // Select
582
743
  effect(() => {
583
744
  element.value = pulseValue.get();
584
745
  });
585
- element.addEventListener('change', () => {
586
- pulseValue.set(element.value);
587
- });
746
+ const handler = () => pulseValue.set(element.value);
747
+ element.addEventListener('change', handler);
748
+ onCleanup(() => element.removeEventListener('change', handler));
588
749
  } else {
589
750
  // Text input, textarea, etc.
590
751
  effect(() => {
@@ -592,9 +753,9 @@ export function model(element, pulseValue) {
592
753
  element.value = pulseValue.get();
593
754
  }
594
755
  });
595
- element.addEventListener('input', () => {
596
- pulseValue.set(element.value);
597
- });
756
+ const handler = () => pulseValue.set(element.value);
757
+ element.addEventListener('input', handler);
758
+ onCleanup(() => element.removeEventListener('input', handler));
598
759
  }
599
760
 
600
761
  return element;
@@ -817,6 +978,9 @@ export function errorBoundary(children, fallback) {
817
978
 
818
979
  /**
819
980
  * Transition helper - animate element enter/exit
981
+ *
982
+ * MEMORY SAFETY: All timers are tracked and cleared on cleanup
983
+ * to prevent callbacks executing on removed elements.
820
984
  */
821
985
  export function transition(element, options = {}) {
822
986
  const {
@@ -827,11 +991,30 @@ export function transition(element, options = {}) {
827
991
  onExit
828
992
  } = options;
829
993
 
994
+ // Track active timers for cleanup
995
+ const activeTimers = new Set();
996
+
997
+ const safeTimeout = (fn, delay) => {
998
+ const timerId = setTimeout(() => {
999
+ activeTimers.delete(timerId);
1000
+ fn();
1001
+ }, delay);
1002
+ activeTimers.add(timerId);
1003
+ return timerId;
1004
+ };
1005
+
1006
+ const clearAllTimers = () => {
1007
+ for (const timerId of activeTimers) {
1008
+ clearTimeout(timerId);
1009
+ }
1010
+ activeTimers.clear();
1011
+ };
1012
+
830
1013
  // Apply enter animation
831
1014
  const applyEnter = () => {
832
1015
  element.classList.add(enter);
833
1016
  if (onEnter) onEnter(element);
834
- setTimeout(() => {
1017
+ safeTimeout(() => {
835
1018
  element.classList.remove(enter);
836
1019
  }, duration);
837
1020
  };
@@ -841,7 +1024,7 @@ export function transition(element, options = {}) {
841
1024
  return new Promise(resolve => {
842
1025
  element.classList.add(exit);
843
1026
  if (onExit) onExit(element);
844
- setTimeout(() => {
1027
+ safeTimeout(() => {
845
1028
  element.classList.remove(exit);
846
1029
  resolve();
847
1030
  }, duration);
@@ -854,11 +1037,17 @@ export function transition(element, options = {}) {
854
1037
  // Attach exit method
855
1038
  element._pulseTransitionExit = applyExit;
856
1039
 
1040
+ // Register cleanup for all timers
1041
+ onCleanup(clearAllTimers);
1042
+
857
1043
  return element;
858
1044
  }
859
1045
 
860
1046
  /**
861
1047
  * Conditional rendering with transitions
1048
+ *
1049
+ * MEMORY SAFETY: All timers are tracked and cleared on cleanup
1050
+ * to prevent callbacks executing on removed elements.
862
1051
  */
863
1052
  export function whenTransition(condition, thenTemplate, elseTemplate = null, options = {}) {
864
1053
  const container = document.createDocumentFragment();
@@ -870,6 +1059,28 @@ export function whenTransition(condition, thenTemplate, elseTemplate = null, opt
870
1059
  let currentNodes = [];
871
1060
  let isTransitioning = false;
872
1061
 
1062
+ // Track active timers for cleanup
1063
+ const activeTimers = new Set();
1064
+
1065
+ const safeTimeout = (fn, delay) => {
1066
+ const timerId = setTimeout(() => {
1067
+ activeTimers.delete(timerId);
1068
+ fn();
1069
+ }, delay);
1070
+ activeTimers.add(timerId);
1071
+ return timerId;
1072
+ };
1073
+
1074
+ const clearAllTimers = () => {
1075
+ for (const timerId of activeTimers) {
1076
+ clearTimeout(timerId);
1077
+ }
1078
+ activeTimers.clear();
1079
+ };
1080
+
1081
+ // Register cleanup for all timers
1082
+ onCleanup(clearAllTimers);
1083
+
873
1084
  effect(() => {
874
1085
  const show = typeof condition === 'function' ? condition() : condition.get();
875
1086
 
@@ -887,7 +1098,7 @@ export function whenTransition(condition, thenTemplate, elseTemplate = null, opt
887
1098
  node.classList.add(exitClass);
888
1099
  }
889
1100
 
890
- setTimeout(() => {
1101
+ safeTimeout(() => {
891
1102
  for (const node of nodesToRemove) {
892
1103
  node.remove();
893
1104
  }
@@ -904,7 +1115,7 @@ export function whenTransition(condition, thenTemplate, elseTemplate = null, opt
904
1115
  node.classList.add(enterClass);
905
1116
  fragment.appendChild(node);
906
1117
  currentNodes.push(node);
907
- setTimeout(() => node.classList.remove(enterClass), duration);
1118
+ safeTimeout(() => node.classList.remove(enterClass), duration);
908
1119
  }
909
1120
  }
910
1121
  marker.parentNode?.insertBefore(fragment, marker.nextSibling);
@@ -922,7 +1133,7 @@ export function whenTransition(condition, thenTemplate, elseTemplate = null, opt
922
1133
  node.classList.add(enterClass);
923
1134
  fragment.appendChild(node);
924
1135
  currentNodes.push(node);
925
- setTimeout(() => node.classList.remove(enterClass), duration);
1136
+ safeTimeout(() => node.classList.remove(enterClass), duration);
926
1137
  }
927
1138
  }
928
1139
  marker.parentNode?.insertBefore(fragment, marker.nextSibling);
@@ -955,5 +1166,8 @@ export default {
955
1166
  portal,
956
1167
  errorBoundary,
957
1168
  transition,
958
- whenTransition
1169
+ whenTransition,
1170
+ // Diagnostics
1171
+ getCacheMetrics,
1172
+ resetCacheMetrics
959
1173
  };