pulse-js-framework 1.7.3 → 1.7.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/runtime/dom.js CHANGED
@@ -2,15 +2,42 @@
2
2
  * Pulse DOM - Declarative DOM manipulation
3
3
  *
4
4
  * Creates DOM elements using CSS selector-like syntax
5
- * and provides reactive bindings
5
+ * and provides reactive bindings.
6
+ *
7
+ * DOM Abstraction:
8
+ * This module uses a DOM adapter for all DOM operations, enabling:
9
+ * - Server-Side Rendering (SSR) with virtual DOM implementations
10
+ * - Simplified testing without browser environment
11
+ * - Platform-specific optimizations
12
+ *
13
+ * @see ./dom-adapter.js for adapter configuration
6
14
  */
7
15
 
8
16
  import { effect, pulse, batch, onCleanup } from './pulse.js';
9
17
  import { loggers } from './logger.js';
10
18
  import { LRUCache } from './lru-cache.js';
19
+ import { safeSetAttribute, sanitizeUrl, safeSetStyle } from './utils.js';
20
+ import { getAdapter } from './dom-adapter.js';
21
+ import { Errors } from '../core/errors.js';
11
22
 
12
23
  const log = loggers.dom;
13
24
 
25
+ // =============================================================================
26
+ // CONFIGURATION
27
+ // =============================================================================
28
+
29
+ /**
30
+ * @typedef {Object} DomConfig
31
+ * @property {number} [selectorCacheCapacity=500] - Max selectors to cache (0 = disabled)
32
+ * @property {boolean} [trackCacheMetrics=true] - Enable cache hit/miss tracking
33
+ */
34
+
35
+ /** @type {DomConfig} */
36
+ const config = {
37
+ selectorCacheCapacity: 500,
38
+ trackCacheMetrics: true
39
+ };
40
+
14
41
  // =============================================================================
15
42
  // SELECTOR CACHE
16
43
  // =============================================================================
@@ -21,13 +48,84 @@ const log = loggers.dom;
21
48
  // - Without eviction, memory grows unbounded in long-running apps
22
49
  // - LRU keeps frequently-used selectors hot while evicting rare ones
23
50
  //
24
- // Capacity: 500 selectors (chosen based on typical SPA component count)
51
+ // Default capacity: 500 selectors (configurable via configureDom())
25
52
  // - Most apps use 50-200 unique selectors
26
53
  // - 500 provides headroom for dynamic selectors without excessive memory
27
54
  //
28
55
  // Cache hit returns a shallow copy to prevent mutation of cached config
29
56
  // Metrics tracking enabled for performance monitoring
30
- const selectorCache = new LRUCache(500, { trackMetrics: true });
57
+ let selectorCache = new LRUCache(config.selectorCacheCapacity, { trackMetrics: config.trackCacheMetrics });
58
+
59
+ /**
60
+ * Configure DOM module settings.
61
+ *
62
+ * Call this before using any DOM functions if you need non-default settings.
63
+ * Changing configuration clears the selector cache.
64
+ *
65
+ * @param {Partial<DomConfig>} options - Configuration options
66
+ * @returns {DomConfig} Current configuration after applying changes
67
+ *
68
+ * @example
69
+ * // Increase cache for large apps
70
+ * configureDom({ selectorCacheCapacity: 1000 });
71
+ *
72
+ * @example
73
+ * // Disable caching for debugging
74
+ * configureDom({ selectorCacheCapacity: 0 });
75
+ *
76
+ * @example
77
+ * // Disable metrics tracking in production
78
+ * configureDom({ trackCacheMetrics: false });
79
+ */
80
+ export function configureDom(options = {}) {
81
+ let cacheChanged = false;
82
+
83
+ if (options.selectorCacheCapacity !== undefined) {
84
+ config.selectorCacheCapacity = options.selectorCacheCapacity;
85
+ cacheChanged = true;
86
+ }
87
+
88
+ if (options.trackCacheMetrics !== undefined) {
89
+ config.trackCacheMetrics = options.trackCacheMetrics;
90
+ cacheChanged = true;
91
+ }
92
+
93
+ // Recreate cache if settings changed
94
+ if (cacheChanged) {
95
+ if (config.selectorCacheCapacity > 0) {
96
+ selectorCache = new LRUCache(config.selectorCacheCapacity, {
97
+ trackMetrics: config.trackCacheMetrics
98
+ });
99
+ } else {
100
+ // Disable caching with a null-like cache
101
+ selectorCache = {
102
+ get: () => undefined,
103
+ set: () => {},
104
+ clear: () => {},
105
+ getMetrics: () => ({ hits: 0, misses: 0, evictions: 0, hitRate: 0, size: 0, capacity: 0 }),
106
+ resetMetrics: () => {}
107
+ };
108
+ }
109
+ }
110
+
111
+ return { ...config };
112
+ }
113
+
114
+ /**
115
+ * Get current DOM configuration.
116
+ * @returns {DomConfig} Current configuration (copy)
117
+ */
118
+ export function getDomConfig() {
119
+ return { ...config };
120
+ }
121
+
122
+ /**
123
+ * Clear the selector cache.
124
+ * Useful for memory management or after significant DOM structure changes.
125
+ */
126
+ export function clearSelectorCache() {
127
+ selectorCache.clear();
128
+ }
31
129
 
32
130
  /**
33
131
  * Get selector cache performance metrics.
@@ -51,23 +149,6 @@ export function resetCacheMetrics() {
51
149
  selectorCache.resetMetrics();
52
150
  }
53
151
 
54
- /**
55
- * Safely insert a node before a reference node
56
- * Returns false if the parent is detached (no parentNode)
57
- * @private
58
- * @param {Node} newNode - Node to insert
59
- * @param {Node} refNode - Reference node (insert before this)
60
- * @returns {boolean} True if insertion succeeded
61
- */
62
- function safeInsertBefore(newNode, refNode) {
63
- if (!refNode.parentNode) {
64
- log.warn('Cannot insert node: reference node has no parent (may be detached)');
65
- return false;
66
- }
67
- refNode.parentNode.insertBefore(newNode, refNode);
68
- return true;
69
- }
70
-
71
152
  /**
72
153
  * Resolve a selector string or element to a DOM element
73
154
  * @private
@@ -77,7 +158,8 @@ function safeInsertBefore(newNode, refNode) {
77
158
  */
78
159
  function resolveSelector(target, context = 'target') {
79
160
  if (typeof target === 'string') {
80
- const element = document.querySelector(target);
161
+ const dom = getAdapter();
162
+ const element = dom.querySelector(target);
81
163
  return { element, selector: target };
82
164
  }
83
165
  return { element: target, selector: '(element)' };
@@ -96,7 +178,8 @@ export function onMount(fn) {
96
178
  currentMountContext.mountCallbacks.push(fn);
97
179
  } else {
98
180
  // Defer to next microtask if no context
99
- queueMicrotask(fn);
181
+ const dom = getAdapter();
182
+ dom.queueMicrotask(fn);
100
183
  }
101
184
  }
102
185
 
@@ -218,19 +301,21 @@ export function parseSelector(selector) {
218
301
  * Create a DOM element from a selector
219
302
  */
220
303
  export function el(selector, ...children) {
304
+ const dom = getAdapter();
221
305
  const config = parseSelector(selector);
222
- const element = document.createElement(config.tag);
306
+ const element = dom.createElement(config.tag);
223
307
 
224
308
  if (config.id) {
225
- element.id = config.id;
309
+ dom.setProperty(element, 'id', config.id);
226
310
  }
227
311
 
228
312
  if (config.classes.length > 0) {
229
- element.className = config.classes.join(' ');
313
+ dom.setProperty(element, 'className', config.classes.join(' '));
230
314
  }
231
315
 
232
316
  for (const [key, value] of Object.entries(config.attrs)) {
233
- element.setAttribute(key, value);
317
+ // Use safeSetAttribute to prevent XSS via event handlers and javascript: URLs
318
+ safeSetAttribute(element, key, value, {}, dom);
234
319
  }
235
320
 
236
321
  // Process children
@@ -245,20 +330,22 @@ export function el(selector, ...children) {
245
330
  * Append a child to an element, handling various types
246
331
  */
247
332
  function appendChild(parent, child) {
333
+ const dom = getAdapter();
334
+
248
335
  if (child == null || child === false) return;
249
336
 
250
337
  if (typeof child === 'string' || typeof child === 'number') {
251
- parent.appendChild(document.createTextNode(String(child)));
252
- } else if (child instanceof Node) {
253
- parent.appendChild(child);
338
+ dom.appendChild(parent, dom.createTextNode(String(child)));
339
+ } else if (dom.isNode(child)) {
340
+ dom.appendChild(parent, child);
254
341
  } else if (Array.isArray(child)) {
255
342
  for (const c of child) {
256
343
  appendChild(parent, c);
257
344
  }
258
345
  } else if (typeof child === 'function') {
259
346
  // Reactive child - create a placeholder and update it
260
- const placeholder = document.createComment('pulse');
261
- parent.appendChild(placeholder);
347
+ const placeholder = dom.createComment('pulse');
348
+ dom.appendChild(parent, placeholder);
262
349
  let currentNodes = [];
263
350
 
264
351
  effect(() => {
@@ -266,34 +353,35 @@ function appendChild(parent, child) {
266
353
 
267
354
  // Remove old nodes
268
355
  for (const node of currentNodes) {
269
- node.remove();
356
+ dom.removeNode(node);
270
357
  }
271
358
  currentNodes = [];
272
359
 
273
360
  // Add new nodes
274
361
  if (result != null && result !== false) {
275
- const fragment = document.createDocumentFragment();
362
+ const fragment = dom.createDocumentFragment();
276
363
  if (typeof result === 'string' || typeof result === 'number') {
277
- const textNode = document.createTextNode(String(result));
278
- fragment.appendChild(textNode);
364
+ const textNode = dom.createTextNode(String(result));
365
+ dom.appendChild(fragment, textNode);
279
366
  currentNodes.push(textNode);
280
- } else if (result instanceof Node) {
281
- fragment.appendChild(result);
367
+ } else if (dom.isNode(result)) {
368
+ dom.appendChild(fragment, result);
282
369
  currentNodes.push(result);
283
370
  } else if (Array.isArray(result)) {
284
371
  for (const r of result) {
285
- if (r instanceof Node) {
286
- fragment.appendChild(r);
372
+ if (dom.isNode(r)) {
373
+ dom.appendChild(fragment, r);
287
374
  currentNodes.push(r);
288
375
  } else if (r != null && r !== false) {
289
- const textNode = document.createTextNode(String(r));
290
- fragment.appendChild(textNode);
376
+ const textNode = dom.createTextNode(String(r));
377
+ dom.appendChild(fragment, textNode);
291
378
  currentNodes.push(textNode);
292
379
  }
293
380
  }
294
381
  }
295
- if (placeholder.parentNode) {
296
- placeholder.parentNode.insertBefore(fragment, placeholder.nextSibling);
382
+ const placeholderParent = dom.getParentNode(placeholder);
383
+ if (placeholderParent) {
384
+ dom.insertBefore(placeholderParent, fragment, dom.getNextSibling(placeholder));
297
385
  } else {
298
386
  log.warn('Cannot insert reactive children: placeholder has no parent node');
299
387
  }
@@ -306,33 +394,74 @@ function appendChild(parent, child) {
306
394
  * Create a reactive text node
307
395
  */
308
396
  export function text(getValue) {
397
+ const dom = getAdapter();
309
398
  if (typeof getValue === 'function') {
310
- const node = document.createTextNode('');
399
+ const node = dom.createTextNode('');
311
400
  effect(() => {
312
- node.textContent = String(getValue());
401
+ dom.setTextContent(node, String(getValue()));
313
402
  });
314
403
  return node;
315
404
  }
316
- return document.createTextNode(String(getValue));
405
+ return dom.createTextNode(String(getValue));
317
406
  }
318
407
 
319
408
  /**
320
- * Bind an attribute reactively
409
+ * URL attributes that need sanitization in bind()
410
+ * @private
411
+ */
412
+ const BIND_URL_ATTRIBUTES = new Set([
413
+ 'href', 'src', 'action', 'formaction', 'data', 'poster',
414
+ 'cite', 'codebase', 'background', 'profile', 'usemap', 'longdesc'
415
+ ]);
416
+
417
+ /**
418
+ * Bind an attribute reactively with XSS protection
419
+ *
420
+ * Security: URL attributes (href, src, etc.) are sanitized to prevent javascript: XSS
321
421
  */
322
422
  export function bind(element, attr, getValue) {
423
+ const dom = getAdapter();
424
+ const lowerAttr = attr.toLowerCase();
425
+ const isUrlAttr = BIND_URL_ATTRIBUTES.has(lowerAttr);
426
+
323
427
  if (typeof getValue === 'function') {
324
428
  effect(() => {
325
429
  const value = getValue();
326
430
  if (value == null || value === false) {
327
- element.removeAttribute(attr);
431
+ dom.removeAttribute(element, attr);
328
432
  } else if (value === true) {
329
- element.setAttribute(attr, '');
433
+ dom.setAttribute(element, attr, '');
330
434
  } else {
331
- element.setAttribute(attr, String(value));
435
+ // Sanitize URL attributes to prevent javascript: XSS
436
+ if (isUrlAttr) {
437
+ const sanitized = sanitizeUrl(String(value));
438
+ if (sanitized === null) {
439
+ console.warn(
440
+ `[Pulse Security] Dangerous URL blocked in bind() for ${attr}: "${String(value).slice(0, 50)}"`
441
+ );
442
+ dom.removeAttribute(element, attr);
443
+ return;
444
+ }
445
+ dom.setAttribute(element, attr, sanitized);
446
+ } else {
447
+ dom.setAttribute(element, attr, String(value));
448
+ }
332
449
  }
333
450
  });
334
451
  } else {
335
- element.setAttribute(attr, String(getValue));
452
+ // Sanitize URL attributes for static values too
453
+ if (isUrlAttr) {
454
+ const sanitized = sanitizeUrl(String(getValue));
455
+ if (sanitized === null) {
456
+ console.warn(
457
+ `[Pulse Security] Dangerous URL blocked in bind() for ${attr}: "${String(getValue).slice(0, 50)}"`
458
+ );
459
+ return element;
460
+ }
461
+ dom.setAttribute(element, attr, sanitized);
462
+ } else {
463
+ dom.setAttribute(element, attr, String(getValue));
464
+ }
336
465
  }
337
466
  return element;
338
467
  }
@@ -341,12 +470,13 @@ export function bind(element, attr, getValue) {
341
470
  * Bind a property reactively
342
471
  */
343
472
  export function prop(element, propName, getValue) {
473
+ const dom = getAdapter();
344
474
  if (typeof getValue === 'function') {
345
475
  effect(() => {
346
- element[propName] = getValue();
476
+ dom.setProperty(element, propName, getValue());
347
477
  });
348
478
  } else {
349
- element[propName] = getValue;
479
+ dom.setProperty(element, propName, getValue);
350
480
  }
351
481
  return element;
352
482
  }
@@ -355,30 +485,43 @@ export function prop(element, propName, getValue) {
355
485
  * Bind CSS class reactively
356
486
  */
357
487
  export function cls(element, className, condition) {
488
+ const dom = getAdapter();
358
489
  if (typeof condition === 'function') {
359
490
  effect(() => {
360
491
  if (condition()) {
361
- element.classList.add(className);
492
+ dom.addClass(element, className);
362
493
  } else {
363
- element.classList.remove(className);
494
+ dom.removeClass(element, className);
364
495
  }
365
496
  });
366
497
  } else if (condition) {
367
- element.classList.add(className);
498
+ dom.addClass(element, className);
368
499
  }
369
500
  return element;
370
501
  }
371
502
 
372
503
  /**
373
- * Bind style property reactively
504
+ * Bind style property reactively with CSS injection protection
505
+ *
506
+ * Security: CSS values are sanitized to prevent injection attacks via:
507
+ * - Semicolons (property injection: 'red; position: fixed')
508
+ * - url() (data exfiltration)
509
+ * - expression() (IE script execution)
510
+ *
511
+ * @param {HTMLElement} element - Target element
512
+ * @param {string} prop - CSS property name
513
+ * @param {*} getValue - Value or function returning value
514
+ * @param {Object} [options] - Options passed to safeSetStyle
515
+ * @returns {HTMLElement} The element for chaining
374
516
  */
375
- export function style(element, prop, getValue) {
517
+ export function style(element, prop, getValue, options = {}) {
518
+ const dom = getAdapter();
376
519
  if (typeof getValue === 'function') {
377
520
  effect(() => {
378
- element.style[prop] = getValue();
521
+ safeSetStyle(element, prop, getValue(), options, dom);
379
522
  });
380
523
  } else {
381
- element.style[prop] = getValue;
524
+ safeSetStyle(element, prop, getValue, options, dom);
382
525
  }
383
526
  return element;
384
527
  }
@@ -387,11 +530,12 @@ export function style(element, prop, getValue) {
387
530
  * Attach an event listener
388
531
  */
389
532
  export function on(element, event, handler, options) {
390
- element.addEventListener(event, handler, options);
533
+ const dom = getAdapter();
534
+ dom.addEventListener(element, event, handler, options);
391
535
 
392
536
  // Auto-cleanup: remove listener when effect is disposed (HMR support)
393
537
  onCleanup(() => {
394
- element.removeEventListener(event, handler, options);
538
+ dom.removeEventListener(element, event, handler, options);
395
539
  });
396
540
 
397
541
  return element;
@@ -483,12 +627,13 @@ function computeLIS(arr) {
483
627
  * @returns {DocumentFragment} Container fragment with reactive list
484
628
  */
485
629
  export function list(getItems, template, keyFn = (item, i) => i) {
486
- const container = document.createDocumentFragment();
487
- const startMarker = document.createComment('list-start');
488
- const endMarker = document.createComment('list-end');
630
+ const dom = getAdapter();
631
+ const container = dom.createDocumentFragment();
632
+ const startMarker = dom.createComment('list-start');
633
+ const endMarker = dom.createComment('list-end');
489
634
 
490
- container.appendChild(startMarker);
491
- container.appendChild(endMarker);
635
+ dom.appendChild(container, startMarker);
636
+ dom.appendChild(container, endMarker);
492
637
 
493
638
  // Map: key -> { nodes: Node[], cleanup: Function, item: any }
494
639
  let itemNodes = new Map();
@@ -529,7 +674,7 @@ export function list(getItems, template, keyFn = (item, i) => i) {
529
674
  for (const [key, entry] of itemNodes) {
530
675
  if (!newItemNodes.has(key)) {
531
676
  for (const node of entry.nodes) {
532
- node.remove();
677
+ dom.removeNode(node);
533
678
  }
534
679
  if (entry.cleanup) entry.cleanup();
535
680
  }
@@ -561,17 +706,17 @@ export function list(getItems, template, keyFn = (item, i) => i) {
561
706
  });
562
707
 
563
708
  // Phase 5: Position nodes with minimal DOM operations
564
- const parent = startMarker.parentNode;
709
+ const parent = dom.getParentNode(startMarker);
565
710
  if (!parent) {
566
711
  // Not yet in DOM, use simple append with DocumentFragment batch
567
- const fragment = document.createDocumentFragment();
712
+ const fragment = dom.createDocumentFragment();
568
713
  for (const key of newKeys) {
569
714
  const entry = newItemNodes.get(key);
570
715
  for (const node of entry.nodes) {
571
- fragment.appendChild(node);
716
+ dom.appendChild(fragment, node);
572
717
  }
573
718
  }
574
- container.insertBefore(fragment, endMarker);
719
+ dom.insertBefore(container, fragment, endMarker);
575
720
  } else {
576
721
  // Optimized reordering: batch consecutive inserts using DocumentFragment
577
722
  let prevNode = startMarker;
@@ -585,14 +730,14 @@ export function list(getItems, template, keyFn = (item, i) => i) {
585
730
  const isStable = stableKeys.has(key);
586
731
 
587
732
  // Check if node is already in correct position
588
- const inPosition = prevNode.nextSibling === firstNode;
733
+ const inPosition = dom.getNextSibling(prevNode) === firstNode;
589
734
 
590
735
  if (inPosition && (isStable || isNew)) {
591
736
  // Already in correct position, just advance
592
737
  prevNode = entry.nodes[entry.nodes.length - 1];
593
738
  } else {
594
739
  // Collect consecutive items that need to be inserted at this position
595
- const fragment = document.createDocumentFragment();
740
+ const fragment = dom.createDocumentFragment();
596
741
  let j = i;
597
742
 
598
743
  while (j < newKeys.length) {
@@ -603,14 +748,14 @@ export function list(getItems, template, keyFn = (item, i) => i) {
603
748
  const s = stableKeys.has(k);
604
749
 
605
750
  // If this item is already in position after prevNode, stop batching
606
- if (j > i && prevNode.nextSibling === f) {
751
+ if (j > i && dom.getNextSibling(prevNode) === f) {
607
752
  break;
608
753
  }
609
754
 
610
755
  // Add to batch if it's new or needs to move
611
- if (n || !s || prevNode.nextSibling !== f) {
756
+ if (n || !s || dom.getNextSibling(prevNode) !== f) {
612
757
  for (const node of e.nodes) {
613
- fragment.appendChild(node);
758
+ dom.appendChild(fragment, node);
614
759
  }
615
760
  j++;
616
761
  } else {
@@ -619,8 +764,8 @@ export function list(getItems, template, keyFn = (item, i) => i) {
619
764
  }
620
765
 
621
766
  // Insert the batch
622
- if (fragment.childNodes.length > 0) {
623
- parent.insertBefore(fragment, prevNode.nextSibling);
767
+ if (dom.getFirstChild(fragment)) {
768
+ dom.insertBefore(parent, fragment, dom.getNextSibling(prevNode));
624
769
  }
625
770
 
626
771
  // Update prevNode to last inserted node
@@ -644,9 +789,10 @@ export function list(getItems, template, keyFn = (item, i) => i) {
644
789
  * Conditional rendering
645
790
  */
646
791
  export function when(condition, thenTemplate, elseTemplate = null) {
647
- const container = document.createDocumentFragment();
648
- const marker = document.createComment('when');
649
- container.appendChild(marker);
792
+ const dom = getAdapter();
793
+ const container = dom.createDocumentFragment();
794
+ const marker = dom.createComment('when');
795
+ dom.appendChild(container, marker);
650
796
 
651
797
  let currentNodes = [];
652
798
  let currentCleanup = null;
@@ -656,7 +802,7 @@ export function when(condition, thenTemplate, elseTemplate = null) {
656
802
 
657
803
  // Cleanup previous
658
804
  for (const node of currentNodes) {
659
- node.remove();
805
+ dom.removeNode(node);
660
806
  }
661
807
  if (currentCleanup) currentCleanup();
662
808
  currentNodes = [];
@@ -668,14 +814,17 @@ export function when(condition, thenTemplate, elseTemplate = null) {
668
814
  const result = typeof template === 'function' ? template() : template;
669
815
  if (result) {
670
816
  const nodes = Array.isArray(result) ? result : [result];
671
- const fragment = document.createDocumentFragment();
817
+ const fragment = dom.createDocumentFragment();
672
818
  for (const node of nodes) {
673
- if (node instanceof Node) {
674
- fragment.appendChild(node);
819
+ if (dom.isNode(node)) {
820
+ dom.appendChild(fragment, node);
675
821
  currentNodes.push(node);
676
822
  }
677
823
  }
678
- marker.parentNode?.insertBefore(fragment, marker.nextSibling);
824
+ const markerParent = dom.getParentNode(marker);
825
+ if (markerParent) {
826
+ dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
827
+ }
679
828
  }
680
829
  }
681
830
  });
@@ -687,7 +836,8 @@ export function when(condition, thenTemplate, elseTemplate = null) {
687
836
  * Switch/case rendering
688
837
  */
689
838
  export function match(getValue, cases) {
690
- const marker = document.createComment('match');
839
+ const dom = getAdapter();
840
+ const marker = dom.createComment('match');
691
841
  let currentNodes = [];
692
842
 
693
843
  effect(() => {
@@ -695,7 +845,7 @@ export function match(getValue, cases) {
695
845
 
696
846
  // Remove old nodes
697
847
  for (const node of currentNodes) {
698
- node.remove();
848
+ dom.removeNode(node);
699
849
  }
700
850
  currentNodes = [];
701
851
 
@@ -705,14 +855,17 @@ export function match(getValue, cases) {
705
855
  const result = typeof template === 'function' ? template() : template;
706
856
  if (result) {
707
857
  const nodes = Array.isArray(result) ? result : [result];
708
- const fragment = document.createDocumentFragment();
858
+ const fragment = dom.createDocumentFragment();
709
859
  for (const node of nodes) {
710
- if (node instanceof Node) {
711
- fragment.appendChild(node);
860
+ if (dom.isNode(node)) {
861
+ dom.appendChild(fragment, node);
712
862
  currentNodes.push(node);
713
863
  }
714
864
  }
715
- marker.parentNode?.insertBefore(fragment, marker.nextSibling);
865
+ const markerParent = dom.getParentNode(marker);
866
+ if (markerParent) {
867
+ dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
868
+ }
716
869
  }
717
870
  }
718
871
  });
@@ -727,35 +880,36 @@ export function match(getValue, cases) {
727
880
  * to prevent memory leaks when the element is removed from the DOM.
728
881
  */
729
882
  export function model(element, pulseValue) {
730
- const tagName = element.tagName.toLowerCase();
731
- const type = element.type?.toLowerCase();
883
+ const dom = getAdapter();
884
+ const tagName = dom.getTagName(element);
885
+ const type = dom.getInputType(element);
732
886
 
733
887
  if (tagName === 'input' && (type === 'checkbox' || type === 'radio')) {
734
888
  // Checkbox/Radio
735
889
  effect(() => {
736
- element.checked = pulseValue.get();
890
+ dom.setProperty(element, 'checked', pulseValue.get());
737
891
  });
738
- const handler = () => pulseValue.set(element.checked);
739
- element.addEventListener('change', handler);
740
- onCleanup(() => element.removeEventListener('change', handler));
892
+ const handler = () => pulseValue.set(dom.getProperty(element, 'checked'));
893
+ dom.addEventListener(element, 'change', handler);
894
+ onCleanup(() => dom.removeEventListener(element, 'change', handler));
741
895
  } else if (tagName === 'select') {
742
896
  // Select
743
897
  effect(() => {
744
- element.value = pulseValue.get();
898
+ dom.setProperty(element, 'value', pulseValue.get());
745
899
  });
746
- const handler = () => pulseValue.set(element.value);
747
- element.addEventListener('change', handler);
748
- onCleanup(() => element.removeEventListener('change', handler));
900
+ const handler = () => pulseValue.set(dom.getProperty(element, 'value'));
901
+ dom.addEventListener(element, 'change', handler);
902
+ onCleanup(() => dom.removeEventListener(element, 'change', handler));
749
903
  } else {
750
904
  // Text input, textarea, etc.
751
905
  effect(() => {
752
- if (element.value !== pulseValue.get()) {
753
- element.value = pulseValue.get();
906
+ if (dom.getProperty(element, 'value') !== pulseValue.get()) {
907
+ dom.setProperty(element, 'value', pulseValue.get());
754
908
  }
755
909
  });
756
- const handler = () => pulseValue.set(element.value);
757
- element.addEventListener('input', handler);
758
- onCleanup(() => element.removeEventListener('input', handler));
910
+ const handler = () => pulseValue.set(dom.getProperty(element, 'value'));
911
+ dom.addEventListener(element, 'input', handler);
912
+ onCleanup(() => dom.removeEventListener(element, 'input', handler));
759
913
  }
760
914
 
761
915
  return element;
@@ -769,18 +923,14 @@ export function model(element, pulseValue) {
769
923
  * @throws {Error} If target element is not found
770
924
  */
771
925
  export function mount(target, element) {
926
+ const dom = getAdapter();
772
927
  const { element: resolved, selector } = resolveSelector(target, 'mount');
773
928
  if (!resolved) {
774
- throw new Error(
775
- `[Pulse] Mount target not found: "${selector}". ` +
776
- `Ensure the element exists in the DOM before mounting. ` +
777
- `Tip: Use document.addEventListener('DOMContentLoaded', () => mount(...)) ` +
778
- `or place your script at the end of <body>.`
779
- );
929
+ throw Errors.mountNotFound(selector);
780
930
  }
781
- resolved.appendChild(element);
931
+ dom.appendChild(resolved, element);
782
932
  return () => {
783
- element.remove();
933
+ dom.removeNode(element);
784
934
  };
785
935
  }
786
936
 
@@ -789,6 +939,7 @@ export function mount(target, element) {
789
939
  */
790
940
  export function component(setup) {
791
941
  return (props = {}) => {
942
+ const dom = getAdapter();
792
943
  const state = {};
793
944
  const methods = {};
794
945
 
@@ -826,7 +977,7 @@ export function component(setup) {
826
977
 
827
978
  // Schedule mount callbacks after DOM insertion
828
979
  if (mountContext.mountCallbacks.length > 0) {
829
- queueMicrotask(() => {
980
+ dom.queueMicrotask(() => {
830
981
  for (const cb of mountContext.mountCallbacks) {
831
982
  try {
832
983
  cb();
@@ -838,7 +989,7 @@ export function component(setup) {
838
989
  }
839
990
 
840
991
  // Store unmount callbacks on the element for later cleanup
841
- if (result instanceof Node && mountContext.unmountCallbacks.length > 0) {
992
+ if (dom.isNode(result) && mountContext.unmountCallbacks.length > 0) {
842
993
  result._pulseUnmount = mountContext.unmountCallbacks;
843
994
  }
844
995
 
@@ -851,9 +1002,10 @@ export function component(setup) {
851
1002
  * Unlike when(), this keeps the element in the DOM but hides it
852
1003
  */
853
1004
  export function show(condition, element) {
1005
+ const dom = getAdapter();
854
1006
  effect(() => {
855
1007
  const shouldShow = typeof condition === 'function' ? condition() : condition.get();
856
- element.style.display = shouldShow ? '' : 'none';
1008
+ dom.setStyle(element, 'display', shouldShow ? '' : 'none');
857
1009
  });
858
1010
  return element;
859
1011
  }
@@ -862,14 +1014,15 @@ export function show(condition, element) {
862
1014
  * Portal - render children into a different DOM location
863
1015
  */
864
1016
  export function portal(children, target) {
1017
+ const dom = getAdapter();
865
1018
  const { element: resolvedTarget, selector } = resolveSelector(target, 'portal');
866
1019
 
867
1020
  if (!resolvedTarget) {
868
1021
  log.warn(`Portal target not found: "${selector}"`);
869
- return document.createComment('portal-target-not-found');
1022
+ return dom.createComment('portal-target-not-found');
870
1023
  }
871
1024
 
872
- const marker = document.createComment('portal');
1025
+ const marker = dom.createComment('portal');
873
1026
  let mountedNodes = [];
874
1027
 
875
1028
  // Handle reactive children
@@ -877,7 +1030,7 @@ export function portal(children, target) {
877
1030
  effect(() => {
878
1031
  // Cleanup previous nodes
879
1032
  for (const node of mountedNodes) {
880
- node.remove();
1033
+ dom.removeNode(node);
881
1034
  if (node._pulseUnmount) {
882
1035
  for (const cb of node._pulseUnmount) cb();
883
1036
  }
@@ -888,8 +1041,8 @@ export function portal(children, target) {
888
1041
  if (result) {
889
1042
  const nodes = Array.isArray(result) ? result : [result];
890
1043
  for (const node of nodes) {
891
- if (node instanceof Node) {
892
- resolvedTarget.appendChild(node);
1044
+ if (dom.isNode(node)) {
1045
+ dom.appendChild(resolvedTarget, node);
893
1046
  mountedNodes.push(node);
894
1047
  }
895
1048
  }
@@ -899,8 +1052,8 @@ export function portal(children, target) {
899
1052
  // Static children
900
1053
  const nodes = Array.isArray(children) ? children : [children];
901
1054
  for (const node of nodes) {
902
- if (node instanceof Node) {
903
- resolvedTarget.appendChild(node);
1055
+ if (dom.isNode(node)) {
1056
+ dom.appendChild(resolvedTarget, node);
904
1057
  mountedNodes.push(node);
905
1058
  }
906
1059
  }
@@ -909,7 +1062,7 @@ export function portal(children, target) {
909
1062
  // Return marker for position tracking, attach cleanup
910
1063
  marker._pulseUnmount = [() => {
911
1064
  for (const node of mountedNodes) {
912
- node.remove();
1065
+ dom.removeNode(node);
913
1066
  if (node._pulseUnmount) {
914
1067
  for (const cb of node._pulseUnmount) cb();
915
1068
  }
@@ -923,9 +1076,10 @@ export function portal(children, target) {
923
1076
  * Error boundary - catch errors in child components
924
1077
  */
925
1078
  export function errorBoundary(children, fallback) {
926
- const container = document.createDocumentFragment();
927
- const marker = document.createComment('error-boundary');
928
- container.appendChild(marker);
1079
+ const dom = getAdapter();
1080
+ const container = dom.createDocumentFragment();
1081
+ const marker = dom.createComment('error-boundary');
1082
+ dom.appendChild(container, marker);
929
1083
 
930
1084
  const error = pulse(null);
931
1085
  let currentNodes = [];
@@ -933,7 +1087,7 @@ export function errorBoundary(children, fallback) {
933
1087
  const renderContent = () => {
934
1088
  // Cleanup previous
935
1089
  for (const node of currentNodes) {
936
- node.remove();
1090
+ dom.removeNode(node);
937
1091
  }
938
1092
  currentNodes = [];
939
1093
 
@@ -949,21 +1103,24 @@ export function errorBoundary(children, fallback) {
949
1103
 
950
1104
  if (result) {
951
1105
  const nodes = Array.isArray(result) ? result : [result];
952
- const fragment = document.createDocumentFragment();
1106
+ const fragment = dom.createDocumentFragment();
953
1107
  for (const node of nodes) {
954
- if (node instanceof Node) {
955
- fragment.appendChild(node);
1108
+ if (dom.isNode(node)) {
1109
+ dom.appendChild(fragment, node);
956
1110
  currentNodes.push(node);
957
1111
  }
958
1112
  }
959
- marker.parentNode?.insertBefore(fragment, marker.nextSibling);
1113
+ const markerParent = dom.getParentNode(marker);
1114
+ if (markerParent) {
1115
+ dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
1116
+ }
960
1117
  }
961
1118
  } catch (e) {
962
1119
  log.error('Error in component:', e);
963
1120
  error.set(e);
964
1121
  // Re-render with error
965
1122
  if (!hasError) {
966
- queueMicrotask(renderContent);
1123
+ dom.queueMicrotask(renderContent);
967
1124
  }
968
1125
  }
969
1126
  };
@@ -983,6 +1140,7 @@ export function errorBoundary(children, fallback) {
983
1140
  * to prevent callbacks executing on removed elements.
984
1141
  */
985
1142
  export function transition(element, options = {}) {
1143
+ const dom = getAdapter();
986
1144
  const {
987
1145
  enter = 'fade-in',
988
1146
  exit = 'fade-out',
@@ -995,7 +1153,7 @@ export function transition(element, options = {}) {
995
1153
  const activeTimers = new Set();
996
1154
 
997
1155
  const safeTimeout = (fn, delay) => {
998
- const timerId = setTimeout(() => {
1156
+ const timerId = dom.setTimeout(() => {
999
1157
  activeTimers.delete(timerId);
1000
1158
  fn();
1001
1159
  }, delay);
@@ -1005,34 +1163,34 @@ export function transition(element, options = {}) {
1005
1163
 
1006
1164
  const clearAllTimers = () => {
1007
1165
  for (const timerId of activeTimers) {
1008
- clearTimeout(timerId);
1166
+ dom.clearTimeout(timerId);
1009
1167
  }
1010
1168
  activeTimers.clear();
1011
1169
  };
1012
1170
 
1013
1171
  // Apply enter animation
1014
1172
  const applyEnter = () => {
1015
- element.classList.add(enter);
1173
+ dom.addClass(element, enter);
1016
1174
  if (onEnter) onEnter(element);
1017
1175
  safeTimeout(() => {
1018
- element.classList.remove(enter);
1176
+ dom.removeClass(element, enter);
1019
1177
  }, duration);
1020
1178
  };
1021
1179
 
1022
1180
  // Apply exit animation and return promise
1023
1181
  const applyExit = () => {
1024
1182
  return new Promise(resolve => {
1025
- element.classList.add(exit);
1183
+ dom.addClass(element, exit);
1026
1184
  if (onExit) onExit(element);
1027
1185
  safeTimeout(() => {
1028
- element.classList.remove(exit);
1186
+ dom.removeClass(element, exit);
1029
1187
  resolve();
1030
1188
  }, duration);
1031
1189
  });
1032
1190
  };
1033
1191
 
1034
1192
  // Apply enter on mount
1035
- queueMicrotask(applyEnter);
1193
+ dom.queueMicrotask(applyEnter);
1036
1194
 
1037
1195
  // Attach exit method
1038
1196
  element._pulseTransitionExit = applyExit;
@@ -1050,9 +1208,10 @@ export function transition(element, options = {}) {
1050
1208
  * to prevent callbacks executing on removed elements.
1051
1209
  */
1052
1210
  export function whenTransition(condition, thenTemplate, elseTemplate = null, options = {}) {
1053
- const container = document.createDocumentFragment();
1054
- const marker = document.createComment('when-transition');
1055
- container.appendChild(marker);
1211
+ const dom = getAdapter();
1212
+ const container = dom.createDocumentFragment();
1213
+ const marker = dom.createComment('when-transition');
1214
+ dom.appendChild(container, marker);
1056
1215
 
1057
1216
  const { duration = 300, enterClass = 'fade-in', exitClass = 'fade-out' } = options;
1058
1217
 
@@ -1063,7 +1222,7 @@ export function whenTransition(condition, thenTemplate, elseTemplate = null, opt
1063
1222
  const activeTimers = new Set();
1064
1223
 
1065
1224
  const safeTimeout = (fn, delay) => {
1066
- const timerId = setTimeout(() => {
1225
+ const timerId = dom.setTimeout(() => {
1067
1226
  activeTimers.delete(timerId);
1068
1227
  fn();
1069
1228
  }, delay);
@@ -1073,7 +1232,7 @@ export function whenTransition(condition, thenTemplate, elseTemplate = null, opt
1073
1232
 
1074
1233
  const clearAllTimers = () => {
1075
1234
  for (const timerId of activeTimers) {
1076
- clearTimeout(timerId);
1235
+ dom.clearTimeout(timerId);
1077
1236
  }
1078
1237
  activeTimers.clear();
1079
1238
  };
@@ -1095,12 +1254,12 @@ export function whenTransition(condition, thenTemplate, elseTemplate = null, opt
1095
1254
  currentNodes = [];
1096
1255
 
1097
1256
  for (const node of nodesToRemove) {
1098
- node.classList.add(exitClass);
1257
+ dom.addClass(node, exitClass);
1099
1258
  }
1100
1259
 
1101
1260
  safeTimeout(() => {
1102
1261
  for (const node of nodesToRemove) {
1103
- node.remove();
1262
+ dom.removeNode(node);
1104
1263
  }
1105
1264
  isTransitioning = false;
1106
1265
 
@@ -1109,16 +1268,19 @@ export function whenTransition(condition, thenTemplate, elseTemplate = null, opt
1109
1268
  const result = typeof template === 'function' ? template() : template;
1110
1269
  if (result) {
1111
1270
  const nodes = Array.isArray(result) ? result : [result];
1112
- const fragment = document.createDocumentFragment();
1271
+ const fragment = dom.createDocumentFragment();
1113
1272
  for (const node of nodes) {
1114
- if (node instanceof Node) {
1115
- node.classList.add(enterClass);
1116
- fragment.appendChild(node);
1273
+ if (dom.isNode(node)) {
1274
+ dom.addClass(node, enterClass);
1275
+ dom.appendChild(fragment, node);
1117
1276
  currentNodes.push(node);
1118
- safeTimeout(() => node.classList.remove(enterClass), duration);
1277
+ safeTimeout(() => dom.removeClass(node, enterClass), duration);
1119
1278
  }
1120
1279
  }
1121
- marker.parentNode?.insertBefore(fragment, marker.nextSibling);
1280
+ const markerParent = dom.getParentNode(marker);
1281
+ if (markerParent) {
1282
+ dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
1283
+ }
1122
1284
  }
1123
1285
  }
1124
1286
  }, duration);
@@ -1127,16 +1289,19 @@ export function whenTransition(condition, thenTemplate, elseTemplate = null, opt
1127
1289
  const result = typeof template === 'function' ? template() : template;
1128
1290
  if (result) {
1129
1291
  const nodes = Array.isArray(result) ? result : [result];
1130
- const fragment = document.createDocumentFragment();
1292
+ const fragment = dom.createDocumentFragment();
1131
1293
  for (const node of nodes) {
1132
- if (node instanceof Node) {
1133
- node.classList.add(enterClass);
1134
- fragment.appendChild(node);
1294
+ if (dom.isNode(node)) {
1295
+ dom.addClass(node, enterClass);
1296
+ dom.appendChild(fragment, node);
1135
1297
  currentNodes.push(node);
1136
- safeTimeout(() => node.classList.remove(enterClass), duration);
1298
+ safeTimeout(() => dom.removeClass(node, enterClass), duration);
1137
1299
  }
1138
1300
  }
1139
- marker.parentNode?.insertBefore(fragment, marker.nextSibling);
1301
+ const markerParent = dom.getParentNode(marker);
1302
+ if (markerParent) {
1303
+ dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
1304
+ }
1140
1305
  }
1141
1306
  }
1142
1307
  });
@@ -1167,6 +1332,10 @@ export default {
1167
1332
  errorBoundary,
1168
1333
  transition,
1169
1334
  whenTransition,
1335
+ // Configuration
1336
+ configureDom,
1337
+ getDomConfig,
1338
+ clearSelectorCache,
1170
1339
  // Diagnostics
1171
1340
  getCacheMetrics,
1172
1341
  resetCacheMetrics