pulse-js-framework 1.7.5 → 1.7.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.
package/runtime/dom.js CHANGED
@@ -11,1331 +11,171 @@
11
11
  * - Platform-specific optimizations
12
12
  *
13
13
  * @see ./dom-adapter.js for adapter configuration
14
+ *
15
+ * Architecture:
16
+ * This module re-exports from specialized sub-modules:
17
+ * - dom-selector.js: CSS selector parsing with LRU caching
18
+ * - dom-element.js: Core element creation (el, text)
19
+ * - dom-binding.js: Reactive attribute, property, class, style, event bindings
20
+ * - dom-list.js: Reactive list rendering with LIS-based diffing
21
+ * - dom-conditional.js: Conditional rendering (when, match, show)
22
+ * - dom-lifecycle.js: Component lifecycle hooks and mounting
23
+ * - dom-advanced.js: Portal, error boundary, transitions
14
24
  */
15
25
 
16
- import { effect, pulse, batch, onCleanup } from './pulse.js';
17
- import { loggers } from './logger.js';
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';
22
-
23
- const log = loggers.dom;
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
-
41
26
  // =============================================================================
42
- // SELECTOR CACHE
27
+ // IMPORTS FROM SUB-MODULES
43
28
  // =============================================================================
44
- // LRU (Least Recently Used) cache for parseSelector results.
45
- //
46
- // Why LRU instead of Map?
47
- // - Apps typically reuse the same selectors (e.g., 'div.container', 'button.primary')
48
- // - Without eviction, memory grows unbounded in long-running apps
49
- // - LRU keeps frequently-used selectors hot while evicting rare ones
50
- //
51
- // Default capacity: 500 selectors (configurable via configureDom())
52
- // - Most apps use 50-200 unique selectors
53
- // - 500 provides headroom for dynamic selectors without excessive memory
54
- //
55
- // Cache hit returns a shallow copy to prevent mutation of cached config
56
- // Metrics tracking enabled for performance monitoring
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
- }
129
-
130
- /**
131
- * Get selector cache performance metrics.
132
- * Useful for debugging and performance tuning.
133
- *
134
- * @returns {{hits: number, misses: number, evictions: number, hitRate: number, size: number, capacity: number}}
135
- * @example
136
- * const stats = getCacheMetrics();
137
- * console.log(`Cache hit rate: ${(stats.hitRate * 100).toFixed(1)}%`);
138
- * console.log(`Cache size: ${stats.size}/${stats.capacity}`);
139
- */
140
- export function getCacheMetrics() {
141
- return selectorCache.getMetrics();
142
- }
143
-
144
- /**
145
- * Reset cache metrics counters.
146
- * Useful for measuring performance over specific time periods.
147
- */
148
- export function resetCacheMetrics() {
149
- selectorCache.resetMetrics();
150
- }
151
-
152
- /**
153
- * Resolve a selector string or element to a DOM element
154
- * @private
155
- * @param {string|HTMLElement} target - CSS selector or DOM element
156
- * @param {string} context - Context name for error messages
157
- * @returns {{element: HTMLElement|null, selector: string}} Resolved element and original selector
158
- */
159
- function resolveSelector(target, context = 'target') {
160
- if (typeof target === 'string') {
161
- const dom = getAdapter();
162
- const element = dom.querySelector(target);
163
- return { element, selector: target };
164
- }
165
- return { element: target, selector: '(element)' };
166
- }
167
-
168
- // Lifecycle tracking
169
- let mountCallbacks = [];
170
- let unmountCallbacks = [];
171
- let currentMountContext = null;
172
-
173
- /**
174
- * Register a callback to run when component mounts
175
- */
176
- export function onMount(fn) {
177
- if (currentMountContext) {
178
- currentMountContext.mountCallbacks.push(fn);
179
- } else {
180
- // Defer to next microtask if no context
181
- const dom = getAdapter();
182
- dom.queueMicrotask(fn);
183
- }
184
- }
185
-
186
- /**
187
- * Register a callback to run when component unmounts
188
- */
189
- export function onUnmount(fn) {
190
- if (currentMountContext) {
191
- currentMountContext.unmountCallbacks.push(fn);
192
- }
193
- // Also register with effect cleanup if in an effect
194
- onCleanup(fn);
195
- }
196
-
197
- /**
198
- * Parse a CSS selector-like string into element configuration
199
- * Results are cached for performance using LRU cache.
200
- *
201
- * Supported syntax:
202
- * - Tag: `div`, `span`, `custom-element`
203
- * - ID: `#app`, `#my-id`, `#_private`
204
- * - Classes: `.class`, `.my-class`, `.-modifier`
205
- * - Attributes: `[attr]`, `[attr=value]`, `[attr="quoted value"]`, `[attr='single quoted']`
206
- *
207
- * Examples:
208
- * "div" -> { tag: "div" }
209
- * "#app" -> { tag: "div", id: "app" }
210
- * ".container" -> { tag: "div", classes: ["container"] }
211
- * "button.primary.large" -> { tag: "button", classes: ["primary", "large"] }
212
- * "input[type=text][placeholder=Name]" -> { tag: "input", attrs: { type: "text", placeholder: "Name" } }
213
- * "div[data-id=\"complex-123\"]" -> { tag: "div", attrs: { "data-id": "complex-123" } }
214
- *
215
- * @param {string} selector - CSS selector-like string
216
- * @returns {{tag: string, id: string|null, classes: string[], attrs: Object}} Parsed configuration
217
- */
218
- export function parseSelector(selector) {
219
- if (!selector || selector === '') {
220
- return { tag: 'div', id: null, classes: [], attrs: {} };
221
- }
222
-
223
- // Check cache first
224
- const cached = selectorCache.get(selector);
225
- if (cached) {
226
- // Return a shallow copy to prevent mutation
227
- return {
228
- tag: cached.tag,
229
- id: cached.id,
230
- classes: [...cached.classes],
231
- attrs: { ...cached.attrs }
232
- };
233
- }
234
-
235
- const config = {
236
- tag: 'div',
237
- id: null,
238
- classes: [],
239
- attrs: {}
240
- };
241
-
242
- let remaining = selector;
243
-
244
- // Match tag name at the start
245
- const tagMatch = remaining.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);
246
- if (tagMatch) {
247
- config.tag = tagMatch[1];
248
- remaining = remaining.slice(tagMatch[0].length);
249
- }
250
-
251
- // Match ID (supports starting with letter, underscore, or hyphen followed by valid chars)
252
- const idMatch = remaining.match(/#([a-zA-Z_-][a-zA-Z0-9-_]*)/);
253
- if (idMatch) {
254
- config.id = idMatch[1];
255
- remaining = remaining.replace(idMatch[0], '');
256
- }
257
-
258
- // Match classes (supports starting with letter, underscore, or hyphen)
259
- const classMatches = remaining.matchAll(/\.([a-zA-Z_-][a-zA-Z0-9-_]*)/g);
260
- for (const match of classMatches) {
261
- config.classes.push(match[1]);
262
- }
263
-
264
- // Match attributes - improved regex handles quoted values with special characters
265
- // Matches: [attr], [attr=value], [attr="quoted value"], [attr='quoted value']
266
- const attrRegex = /\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/g;
267
- const attrMatches = remaining.matchAll(attrRegex);
268
- for (const match of attrMatches) {
269
- const key = match[1];
270
- // Value can be in match[2] (double-quoted), match[3] (single-quoted), or match[4] (unquoted)
271
- const value = match[2] ?? match[3] ?? match[4] ?? '';
272
- config.attrs[key] = value;
273
- }
274
-
275
- // Validate: check for unparsed content (malformed selector parts)
276
- // Remove all parsed parts to see if anything remains
277
- let unparsed = remaining
278
- .replace(/#[a-zA-Z_-][a-zA-Z0-9-_]*/g, '') // Remove IDs
279
- .replace(/\.[a-zA-Z_-][a-zA-Z0-9-_]*/g, '') // Remove classes
280
- .replace(/\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"[^"]*"|'[^']*'|[^\]]*))?\]/g, '') // Remove attrs
281
- .trim();
282
-
283
- if (unparsed) {
284
- log.warn(`Selector "${selector}" contains unrecognized parts: "${unparsed}". ` +
285
- 'Supported syntax: tag#id.class[attr=value]');
286
- }
287
-
288
- // Cache the result (LRU cache handles eviction automatically)
289
- selectorCache.set(selector, config);
290
-
291
- // Return a copy
292
- return {
293
- tag: config.tag,
294
- id: config.id,
295
- classes: [...config.classes],
296
- attrs: { ...config.attrs }
297
- };
298
- }
299
-
300
- /**
301
- * Create a DOM element from a selector
302
- */
303
- export function el(selector, ...children) {
304
- const dom = getAdapter();
305
- const config = parseSelector(selector);
306
- const element = dom.createElement(config.tag);
307
-
308
- if (config.id) {
309
- dom.setProperty(element, 'id', config.id);
310
- }
311
-
312
- if (config.classes.length > 0) {
313
- dom.setProperty(element, 'className', config.classes.join(' '));
314
- }
315
-
316
- for (const [key, value] of Object.entries(config.attrs)) {
317
- // Use safeSetAttribute to prevent XSS via event handlers and javascript: URLs
318
- safeSetAttribute(element, key, value, {}, dom);
319
- }
320
-
321
- // Process children
322
- for (const child of children) {
323
- appendChild(element, child);
324
- }
325
-
326
- return element;
327
- }
328
-
329
- /**
330
- * Append a child to an element, handling various types
331
- */
332
- function appendChild(parent, child) {
333
- const dom = getAdapter();
334
-
335
- if (child == null || child === false) return;
336
-
337
- if (typeof child === 'string' || typeof child === 'number') {
338
- dom.appendChild(parent, dom.createTextNode(String(child)));
339
- } else if (dom.isNode(child)) {
340
- dom.appendChild(parent, child);
341
- } else if (Array.isArray(child)) {
342
- for (const c of child) {
343
- appendChild(parent, c);
344
- }
345
- } else if (typeof child === 'function') {
346
- // Reactive child - create a placeholder and update it
347
- const placeholder = dom.createComment('pulse');
348
- dom.appendChild(parent, placeholder);
349
- let currentNodes = [];
350
-
351
- effect(() => {
352
- const result = child();
353
-
354
- // Remove old nodes
355
- for (const node of currentNodes) {
356
- dom.removeNode(node);
357
- }
358
- currentNodes = [];
359
-
360
- // Add new nodes
361
- if (result != null && result !== false) {
362
- const fragment = dom.createDocumentFragment();
363
- if (typeof result === 'string' || typeof result === 'number') {
364
- const textNode = dom.createTextNode(String(result));
365
- dom.appendChild(fragment, textNode);
366
- currentNodes.push(textNode);
367
- } else if (dom.isNode(result)) {
368
- dom.appendChild(fragment, result);
369
- currentNodes.push(result);
370
- } else if (Array.isArray(result)) {
371
- for (const r of result) {
372
- if (dom.isNode(r)) {
373
- dom.appendChild(fragment, r);
374
- currentNodes.push(r);
375
- } else if (r != null && r !== false) {
376
- const textNode = dom.createTextNode(String(r));
377
- dom.appendChild(fragment, textNode);
378
- currentNodes.push(textNode);
379
- }
380
- }
381
- }
382
- const placeholderParent = dom.getParentNode(placeholder);
383
- if (placeholderParent) {
384
- dom.insertBefore(placeholderParent, fragment, dom.getNextSibling(placeholder));
385
- } else {
386
- log.warn('Cannot insert reactive children: placeholder has no parent node');
387
- }
388
- }
389
- });
390
- }
391
- }
392
-
393
- /**
394
- * Create a reactive text node
395
- */
396
- export function text(getValue) {
397
- const dom = getAdapter();
398
- if (typeof getValue === 'function') {
399
- const node = dom.createTextNode('');
400
- effect(() => {
401
- dom.setTextContent(node, String(getValue()));
402
- });
403
- return node;
404
- }
405
- return dom.createTextNode(String(getValue));
406
- }
407
-
408
- /**
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
421
- */
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
-
427
- if (typeof getValue === 'function') {
428
- effect(() => {
429
- const value = getValue();
430
- if (value == null || value === false) {
431
- dom.removeAttribute(element, attr);
432
- } else if (value === true) {
433
- dom.setAttribute(element, attr, '');
434
- } else {
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
- }
449
- }
450
- });
451
- } else {
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
- }
465
- }
466
- return element;
467
- }
468
-
469
- /**
470
- * Bind a property reactively
471
- */
472
- export function prop(element, propName, getValue) {
473
- const dom = getAdapter();
474
- if (typeof getValue === 'function') {
475
- effect(() => {
476
- dom.setProperty(element, propName, getValue());
477
- });
478
- } else {
479
- dom.setProperty(element, propName, getValue);
480
- }
481
- return element;
482
- }
483
-
484
- /**
485
- * Bind CSS class reactively
486
- */
487
- export function cls(element, className, condition) {
488
- const dom = getAdapter();
489
- if (typeof condition === 'function') {
490
- effect(() => {
491
- if (condition()) {
492
- dom.addClass(element, className);
493
- } else {
494
- dom.removeClass(element, className);
495
- }
496
- });
497
- } else if (condition) {
498
- dom.addClass(element, className);
499
- }
500
- return element;
501
- }
502
-
503
- /**
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
516
- */
517
- export function style(element, prop, getValue, options = {}) {
518
- const dom = getAdapter();
519
- if (typeof getValue === 'function') {
520
- effect(() => {
521
- safeSetStyle(element, prop, getValue(), options, dom);
522
- });
523
- } else {
524
- safeSetStyle(element, prop, getValue, options, dom);
525
- }
526
- return element;
527
- }
528
-
529
- /**
530
- * Attach an event listener
531
- */
532
- export function on(element, event, handler, options) {
533
- const dom = getAdapter();
534
- dom.addEventListener(element, event, handler, options);
535
-
536
- // Auto-cleanup: remove listener when effect is disposed (HMR support)
537
- onCleanup(() => {
538
- dom.removeEventListener(element, event, handler, options);
539
- });
540
-
541
- return element;
542
- }
543
-
544
- /**
545
- * Compute Longest Increasing Subsequence indices
546
- * Used to minimize DOM moves during list reconciliation
547
- * @private
548
- * @param {number[]} arr - Array of indices
549
- * @returns {number[]} Indices of elements in the LIS
550
- */
551
- function computeLIS(arr) {
552
- const n = arr.length;
553
- if (n === 0) return [];
554
-
555
- // dp[i] = smallest tail of LIS of length i+1
556
- const dp = [];
557
- // parent[i] = index of previous element in LIS ending at i
558
- const parent = new Array(n).fill(-1);
559
- // indices[i] = index in original array of dp[i]
560
- const indices = [];
561
-
562
- for (let i = 0; i < n; i++) {
563
- const val = arr[i];
564
-
565
- // Binary search for position
566
- let lo = 0, hi = dp.length;
567
- while (lo < hi) {
568
- const mid = (lo + hi) >> 1;
569
- if (dp[mid] < val) lo = mid + 1;
570
- else hi = mid;
571
- }
572
-
573
- if (lo === dp.length) {
574
- dp.push(val);
575
- indices.push(i);
576
- } else {
577
- dp[lo] = val;
578
- indices[lo] = i;
579
- }
580
-
581
- parent[i] = lo > 0 ? indices[lo - 1] : -1;
582
- }
583
-
584
- // Reconstruct LIS indices
585
- const lis = [];
586
- let idx = indices[dp.length - 1];
587
- while (idx !== -1) {
588
- lis.push(idx);
589
- idx = parent[idx];
590
- }
591
-
592
- return lis.reverse();
593
- }
594
-
595
- /**
596
- * Create a reactive list with efficient keyed diffing
597
- *
598
- * LIST DIFFING ALGORITHM:
599
- * -----------------------
600
- * This uses a keyed reconciliation strategy to minimize DOM operations:
601
- *
602
- * 1. KEY EXTRACTION: Each item gets a unique key via keyFn (defaults to index)
603
- * Good keys: item.id, item.uuid (stable across re-renders)
604
- * Bad keys: array index (causes unnecessary re-renders on reorder)
605
- *
606
- * 2. RECONCILIATION PHASES:
607
- * a) Build a map of new items by key
608
- * b) For existing keys: reuse the DOM nodes (no re-creation)
609
- * c) For removed keys: remove DOM nodes and run cleanup
610
- * d) For new keys: batch create via DocumentFragment
611
- *
612
- * 3. REORDERING: Uses LIS (Longest Increasing Subsequence):
613
- * - Computes which nodes are already in correct relative order
614
- * - Only moves nodes NOT in the LIS (minimizes DOM operations)
615
- * - New items are batched into DocumentFragment before insertion
616
- *
617
- * 4. BOUNDARY MARKERS: Uses comment nodes to track list boundaries:
618
- * - startMarker: Insertion point for first item
619
- * - endMarker: End boundary (not currently used but reserved)
620
- *
621
- * COMPLEXITY: O(n log n) for LIS + O(m) DOM moves where m = n - LIS length
622
- * Best case (append only): O(n) with single DocumentFragment insert
623
- *
624
- * @param {Function|Pulse} getItems - Items source (reactive)
625
- * @param {Function} template - (item, index) => Node | Node[]
626
- * @param {Function} keyFn - (item, index) => key (default: index)
627
- * @returns {DocumentFragment} Container fragment with reactive list
628
- */
629
- export function list(getItems, template, keyFn = (item, i) => i) {
630
- const dom = getAdapter();
631
- const container = dom.createDocumentFragment();
632
- const startMarker = dom.createComment('list-start');
633
- const endMarker = dom.createComment('list-end');
634
-
635
- dom.appendChild(container, startMarker);
636
- dom.appendChild(container, endMarker);
637
-
638
- // Map: key -> { nodes: Node[], cleanup: Function, item: any }
639
- let itemNodes = new Map();
640
- let keyOrder = []; // Track order of keys for diffing
641
-
642
- effect(() => {
643
- const items = typeof getItems === 'function' ? getItems() : getItems.get();
644
- const itemsArray = Array.isArray(items) ? items : Array.from(items);
645
-
646
- const newKeys = [];
647
- const newItemNodes = new Map();
648
- const newItems = []; // Track new items for batched insertion
649
-
650
- // Phase 1: Build map of new items by key
651
- itemsArray.forEach((item, index) => {
652
- const key = keyFn(item, index);
653
- newKeys.push(key);
654
-
655
- if (itemNodes.has(key)) {
656
- // Reuse existing entry
657
- newItemNodes.set(key, itemNodes.get(key));
658
- } else {
659
- // Mark as new (will batch create later)
660
- newItems.push({ key, item, index });
661
- }
662
- });
663
-
664
- // Phase 2: Batch create new nodes using DocumentFragment
665
- if (newItems.length > 0) {
666
- for (const { key, item, index } of newItems) {
667
- const result = template(item, index);
668
- const nodes = Array.isArray(result) ? result : [result];
669
- newItemNodes.set(key, { nodes, cleanup: null, item });
670
- }
671
- }
672
-
673
- // Phase 3: Remove items that are no longer present
674
- for (const [key, entry] of itemNodes) {
675
- if (!newItemNodes.has(key)) {
676
- for (const node of entry.nodes) {
677
- dom.removeNode(node);
678
- }
679
- if (entry.cleanup) entry.cleanup();
680
- }
681
- }
682
-
683
- // Phase 4: Efficient reordering using LIS algorithm
684
- // Build old position map for existing keys
685
- const oldKeyIndex = new Map();
686
- keyOrder.forEach((key, i) => oldKeyIndex.set(key, i));
687
-
688
- // Get indices of existing items in old order
689
- const existingIndices = [];
690
- const existingKeys = [];
691
- for (let i = 0; i < newKeys.length; i++) {
692
- const key = newKeys[i];
693
- if (oldKeyIndex.has(key)) {
694
- existingIndices.push(oldKeyIndex.get(key));
695
- existingKeys.push(key);
696
- }
697
- }
698
-
699
- // Compute LIS - these nodes don't need to move
700
- const lisIndices = new Set(computeLIS(existingIndices));
701
- const stableKeys = new Set();
702
- existingKeys.forEach((key, i) => {
703
- if (lisIndices.has(i)) {
704
- stableKeys.add(key);
705
- }
706
- });
707
-
708
- // Phase 5: Position nodes with minimal DOM operations
709
- const parent = dom.getParentNode(startMarker);
710
- if (!parent) {
711
- // Not yet in DOM, use simple append with DocumentFragment batch
712
- const fragment = dom.createDocumentFragment();
713
- for (const key of newKeys) {
714
- const entry = newItemNodes.get(key);
715
- for (const node of entry.nodes) {
716
- dom.appendChild(fragment, node);
717
- }
718
- }
719
- dom.insertBefore(container, fragment, endMarker);
720
- } else {
721
- // Optimized reordering: batch consecutive inserts using DocumentFragment
722
- let prevNode = startMarker;
723
-
724
- // Process items in new order
725
- for (let i = 0; i < newKeys.length; i++) {
726
- const key = newKeys[i];
727
- const entry = newItemNodes.get(key);
728
- const firstNode = entry.nodes[0];
729
- const isNew = !oldKeyIndex.has(key);
730
- const isStable = stableKeys.has(key);
731
-
732
- // Check if node is already in correct position
733
- const inPosition = dom.getNextSibling(prevNode) === firstNode;
734
-
735
- if (inPosition && (isStable || isNew)) {
736
- // Already in correct position, just advance
737
- prevNode = entry.nodes[entry.nodes.length - 1];
738
- } else {
739
- // Collect consecutive items that need to be inserted at this position
740
- const fragment = dom.createDocumentFragment();
741
- let j = i;
742
29
 
743
- while (j < newKeys.length) {
744
- const k = newKeys[j];
745
- const e = newItemNodes.get(k);
746
- const f = e.nodes[0];
747
- const n = !oldKeyIndex.has(k);
748
- const s = stableKeys.has(k);
749
-
750
- // If this item is already in position after prevNode, stop batching
751
- if (j > i && dom.getNextSibling(prevNode) === f) {
752
- break;
753
- }
754
-
755
- // Add to batch if it's new or needs to move
756
- if (n || !s || dom.getNextSibling(prevNode) !== f) {
757
- for (const node of e.nodes) {
758
- dom.appendChild(fragment, node);
759
- }
760
- j++;
761
- } else {
762
- break;
763
- }
764
- }
765
-
766
- // Insert the batch
767
- if (dom.getFirstChild(fragment)) {
768
- dom.insertBefore(parent, fragment, dom.getNextSibling(prevNode));
769
- }
770
-
771
- // Update prevNode to last inserted node
772
- if (j > i) {
773
- const lastEntry = newItemNodes.get(newKeys[j - 1]);
774
- prevNode = lastEntry.nodes[lastEntry.nodes.length - 1];
775
- i = j - 1; // Continue from where we left off
776
- }
777
- }
778
- }
779
- }
780
-
781
- itemNodes = newItemNodes;
782
- keyOrder = newKeys;
783
- });
784
-
785
- return container;
786
- }
787
-
788
- /**
789
- * Conditional rendering
790
- */
791
- export function when(condition, thenTemplate, elseTemplate = null) {
792
- const dom = getAdapter();
793
- const container = dom.createDocumentFragment();
794
- const marker = dom.createComment('when');
795
- dom.appendChild(container, marker);
796
-
797
- let currentNodes = [];
798
- let currentCleanup = null;
799
-
800
- effect(() => {
801
- const show = typeof condition === 'function' ? condition() : condition.get();
802
-
803
- // Cleanup previous
804
- for (const node of currentNodes) {
805
- dom.removeNode(node);
806
- }
807
- if (currentCleanup) currentCleanup();
808
- currentNodes = [];
809
- currentCleanup = null;
810
-
811
- // Render new
812
- const template = show ? thenTemplate : elseTemplate;
813
- if (template) {
814
- const result = typeof template === 'function' ? template() : template;
815
- if (result) {
816
- const nodes = Array.isArray(result) ? result : [result];
817
- const fragment = dom.createDocumentFragment();
818
- for (const node of nodes) {
819
- if (dom.isNode(node)) {
820
- dom.appendChild(fragment, node);
821
- currentNodes.push(node);
822
- }
823
- }
824
- const markerParent = dom.getParentNode(marker);
825
- if (markerParent) {
826
- dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
827
- }
828
- }
829
- }
830
- });
831
-
832
- return container;
833
- }
834
-
835
- /**
836
- * Switch/case rendering
837
- */
838
- export function match(getValue, cases) {
839
- const dom = getAdapter();
840
- const marker = dom.createComment('match');
841
- let currentNodes = [];
842
-
843
- effect(() => {
844
- const value = typeof getValue === 'function' ? getValue() : getValue.get();
845
-
846
- // Remove old nodes
847
- for (const node of currentNodes) {
848
- dom.removeNode(node);
849
- }
850
- currentNodes = [];
851
-
852
- // Find matching case
853
- const template = cases[value] ?? cases.default;
854
- if (template) {
855
- const result = typeof template === 'function' ? template() : template;
856
- if (result) {
857
- const nodes = Array.isArray(result) ? result : [result];
858
- const fragment = dom.createDocumentFragment();
859
- for (const node of nodes) {
860
- if (dom.isNode(node)) {
861
- dom.appendChild(fragment, node);
862
- currentNodes.push(node);
863
- }
864
- }
865
- const markerParent = dom.getParentNode(marker);
866
- if (markerParent) {
867
- dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
868
- }
869
- }
870
- }
871
- });
872
-
873
- return marker;
874
- }
875
-
876
- /**
877
- * Two-way binding for form inputs
878
- *
879
- * MEMORY SAFETY: All event listeners are registered with onCleanup()
880
- * to prevent memory leaks when the element is removed from the DOM.
881
- */
882
- export function model(element, pulseValue) {
883
- const dom = getAdapter();
884
- const tagName = dom.getTagName(element);
885
- const type = dom.getInputType(element);
886
-
887
- if (tagName === 'input' && (type === 'checkbox' || type === 'radio')) {
888
- // Checkbox/Radio
889
- effect(() => {
890
- dom.setProperty(element, 'checked', pulseValue.get());
891
- });
892
- const handler = () => pulseValue.set(dom.getProperty(element, 'checked'));
893
- dom.addEventListener(element, 'change', handler);
894
- onCleanup(() => dom.removeEventListener(element, 'change', handler));
895
- } else if (tagName === 'select') {
896
- // Select
897
- effect(() => {
898
- dom.setProperty(element, 'value', pulseValue.get());
899
- });
900
- const handler = () => pulseValue.set(dom.getProperty(element, 'value'));
901
- dom.addEventListener(element, 'change', handler);
902
- onCleanup(() => dom.removeEventListener(element, 'change', handler));
903
- } else {
904
- // Text input, textarea, etc.
905
- effect(() => {
906
- if (dom.getProperty(element, 'value') !== pulseValue.get()) {
907
- dom.setProperty(element, 'value', pulseValue.get());
908
- }
909
- });
910
- const handler = () => pulseValue.set(dom.getProperty(element, 'value'));
911
- dom.addEventListener(element, 'input', handler);
912
- onCleanup(() => dom.removeEventListener(element, 'input', handler));
913
- }
914
-
915
- return element;
916
- }
917
-
918
- /**
919
- * Mount an element to a target
920
- * @param {string|HTMLElement} target - CSS selector or DOM element
921
- * @param {Node} element - Element to mount
922
- * @returns {Function} Unmount function
923
- * @throws {Error} If target element is not found
924
- */
925
- export function mount(target, element) {
926
- const dom = getAdapter();
927
- const { element: resolved, selector } = resolveSelector(target, 'mount');
928
- if (!resolved) {
929
- throw Errors.mountNotFound(selector);
930
- }
931
- dom.appendChild(resolved, element);
932
- return () => {
933
- dom.removeNode(element);
934
- };
935
- }
936
-
937
- /**
938
- * Create a component factory with lifecycle support
939
- */
940
- export function component(setup) {
941
- return (props = {}) => {
942
- const dom = getAdapter();
943
- const state = {};
944
- const methods = {};
945
-
946
- // Create mount context for lifecycle hooks
947
- const mountContext = {
948
- mountCallbacks: [],
949
- unmountCallbacks: []
950
- };
951
-
952
- const prevContext = currentMountContext;
953
- currentMountContext = mountContext;
954
-
955
- const ctx = {
956
- state,
957
- methods,
958
- props,
959
- pulse,
960
- el,
961
- text,
962
- list,
963
- when,
964
- on,
965
- bind,
966
- model,
967
- onMount,
968
- onUnmount
969
- };
970
-
971
- let result;
972
- try {
973
- result = setup(ctx);
974
- } finally {
975
- currentMountContext = prevContext;
976
- }
977
-
978
- // Schedule mount callbacks after DOM insertion
979
- if (mountContext.mountCallbacks.length > 0) {
980
- dom.queueMicrotask(() => {
981
- for (const cb of mountContext.mountCallbacks) {
982
- try {
983
- cb();
984
- } catch (e) {
985
- log.error('Mount callback error:', e);
986
- }
987
- }
988
- });
989
- }
990
-
991
- // Store unmount callbacks on the element for later cleanup
992
- if (dom.isNode(result) && mountContext.unmountCallbacks.length > 0) {
993
- result._pulseUnmount = mountContext.unmountCallbacks;
994
- }
995
-
996
- return result;
997
- };
998
- }
999
-
1000
- /**
1001
- * Toggle element visibility without removing from DOM
1002
- * Unlike when(), this keeps the element in the DOM but hides it
1003
- */
1004
- export function show(condition, element) {
1005
- const dom = getAdapter();
1006
- effect(() => {
1007
- const shouldShow = typeof condition === 'function' ? condition() : condition.get();
1008
- dom.setStyle(element, 'display', shouldShow ? '' : 'none');
1009
- });
1010
- return element;
1011
- }
1012
-
1013
- /**
1014
- * Portal - render children into a different DOM location
1015
- */
1016
- export function portal(children, target) {
1017
- const dom = getAdapter();
1018
- const { element: resolvedTarget, selector } = resolveSelector(target, 'portal');
1019
-
1020
- if (!resolvedTarget) {
1021
- log.warn(`Portal target not found: "${selector}"`);
1022
- return dom.createComment('portal-target-not-found');
1023
- }
1024
-
1025
- const marker = dom.createComment('portal');
1026
- let mountedNodes = [];
1027
-
1028
- // Handle reactive children
1029
- if (typeof children === 'function') {
1030
- effect(() => {
1031
- // Cleanup previous nodes
1032
- for (const node of mountedNodes) {
1033
- dom.removeNode(node);
1034
- if (node._pulseUnmount) {
1035
- for (const cb of node._pulseUnmount) cb();
1036
- }
1037
- }
1038
- mountedNodes = [];
1039
-
1040
- const result = children();
1041
- if (result) {
1042
- const nodes = Array.isArray(result) ? result : [result];
1043
- for (const node of nodes) {
1044
- if (dom.isNode(node)) {
1045
- dom.appendChild(resolvedTarget, node);
1046
- mountedNodes.push(node);
1047
- }
1048
- }
1049
- }
1050
- });
1051
- } else {
1052
- // Static children
1053
- const nodes = Array.isArray(children) ? children : [children];
1054
- for (const node of nodes) {
1055
- if (dom.isNode(node)) {
1056
- dom.appendChild(resolvedTarget, node);
1057
- mountedNodes.push(node);
1058
- }
1059
- }
1060
- }
1061
-
1062
- // Return marker for position tracking, attach cleanup
1063
- marker._pulseUnmount = [() => {
1064
- for (const node of mountedNodes) {
1065
- dom.removeNode(node);
1066
- if (node._pulseUnmount) {
1067
- for (const cb of node._pulseUnmount) cb();
1068
- }
1069
- }
1070
- }];
1071
-
1072
- return marker;
1073
- }
1074
-
1075
- /**
1076
- * Error boundary - catch errors in child components
1077
- */
1078
- export function errorBoundary(children, fallback) {
1079
- const dom = getAdapter();
1080
- const container = dom.createDocumentFragment();
1081
- const marker = dom.createComment('error-boundary');
1082
- dom.appendChild(container, marker);
1083
-
1084
- const error = pulse(null);
1085
- let currentNodes = [];
1086
-
1087
- const renderContent = () => {
1088
- // Cleanup previous
1089
- for (const node of currentNodes) {
1090
- dom.removeNode(node);
1091
- }
1092
- currentNodes = [];
1093
-
1094
- const hasError = error.peek();
1095
-
1096
- try {
1097
- let result;
1098
- if (hasError && fallback) {
1099
- result = typeof fallback === 'function' ? fallback(hasError) : fallback;
1100
- } else {
1101
- result = typeof children === 'function' ? children() : children;
1102
- }
1103
-
1104
- if (result) {
1105
- const nodes = Array.isArray(result) ? result : [result];
1106
- const fragment = dom.createDocumentFragment();
1107
- for (const node of nodes) {
1108
- if (dom.isNode(node)) {
1109
- dom.appendChild(fragment, node);
1110
- currentNodes.push(node);
1111
- }
1112
- }
1113
- const markerParent = dom.getParentNode(marker);
1114
- if (markerParent) {
1115
- dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
1116
- }
1117
- }
1118
- } catch (e) {
1119
- log.error('Error in component:', e);
1120
- error.set(e);
1121
- // Re-render with error
1122
- if (!hasError) {
1123
- dom.queueMicrotask(renderContent);
1124
- }
1125
- }
1126
- };
1127
-
1128
- effect(renderContent);
1129
-
1130
- // Expose reset method on marker
1131
- marker.resetError = () => error.set(null);
1132
-
1133
- return container;
1134
- }
1135
-
1136
- /**
1137
- * Transition helper - animate element enter/exit
1138
- *
1139
- * MEMORY SAFETY: All timers are tracked and cleared on cleanup
1140
- * to prevent callbacks executing on removed elements.
1141
- */
1142
- export function transition(element, options = {}) {
1143
- const dom = getAdapter();
1144
- const {
1145
- enter = 'fade-in',
1146
- exit = 'fade-out',
1147
- duration = 300,
1148
- onEnter,
1149
- onExit
1150
- } = options;
1151
-
1152
- // Track active timers for cleanup
1153
- const activeTimers = new Set();
1154
-
1155
- const safeTimeout = (fn, delay) => {
1156
- const timerId = dom.setTimeout(() => {
1157
- activeTimers.delete(timerId);
1158
- fn();
1159
- }, delay);
1160
- activeTimers.add(timerId);
1161
- return timerId;
1162
- };
1163
-
1164
- const clearAllTimers = () => {
1165
- for (const timerId of activeTimers) {
1166
- dom.clearTimeout(timerId);
1167
- }
1168
- activeTimers.clear();
1169
- };
1170
-
1171
- // Apply enter animation
1172
- const applyEnter = () => {
1173
- dom.addClass(element, enter);
1174
- if (onEnter) onEnter(element);
1175
- safeTimeout(() => {
1176
- dom.removeClass(element, enter);
1177
- }, duration);
1178
- };
1179
-
1180
- // Apply exit animation and return promise
1181
- const applyExit = () => {
1182
- return new Promise(resolve => {
1183
- dom.addClass(element, exit);
1184
- if (onExit) onExit(element);
1185
- safeTimeout(() => {
1186
- dom.removeClass(element, exit);
1187
- resolve();
1188
- }, duration);
1189
- });
1190
- };
1191
-
1192
- // Apply enter on mount
1193
- dom.queueMicrotask(applyEnter);
30
+ // Selector parsing and caching
31
+ import {
32
+ parseSelector,
33
+ resolveSelector,
34
+ configureDom,
35
+ getDomConfig,
36
+ clearSelectorCache,
37
+ getCacheMetrics,
38
+ resetCacheMetrics
39
+ } from './dom-selector.js';
1194
40
 
1195
- // Attach exit method
1196
- element._pulseTransitionExit = applyExit;
41
+ // Core element creation
42
+ import { el, text } from './dom-element.js';
1197
43
 
1198
- // Register cleanup for all timers
1199
- onCleanup(clearAllTimers);
44
+ // Reactive bindings
45
+ import { bind, prop, cls, style, on, model } from './dom-binding.js';
1200
46
 
1201
- return element;
1202
- }
47
+ // List rendering
48
+ import { list, computeLIS } from './dom-list.js';
1203
49
 
1204
- /**
1205
- * Conditional rendering with transitions
1206
- *
1207
- * MEMORY SAFETY: All timers are tracked and cleared on cleanup
1208
- * to prevent callbacks executing on removed elements.
1209
- */
1210
- export function whenTransition(condition, thenTemplate, elseTemplate = null, options = {}) {
1211
- const dom = getAdapter();
1212
- const container = dom.createDocumentFragment();
1213
- const marker = dom.createComment('when-transition');
1214
- dom.appendChild(container, marker);
50
+ // Conditional rendering
51
+ import { when, match, show } from './dom-conditional.js';
1215
52
 
1216
- const { duration = 300, enterClass = 'fade-in', exitClass = 'fade-out' } = options;
53
+ // Lifecycle and mounting
54
+ import {
55
+ onMount,
56
+ onUnmount,
57
+ mount,
58
+ component,
59
+ getMountContext,
60
+ setMountContext,
61
+ _setContextUtils
62
+ } from './dom-lifecycle.js';
1217
63
 
1218
- let currentNodes = [];
1219
- let isTransitioning = false;
64
+ // Advanced features
65
+ import { portal, errorBoundary, transition, whenTransition } from './dom-advanced.js';
1220
66
 
1221
- // Track active timers for cleanup
1222
- const activeTimers = new Set();
67
+ // =============================================================================
68
+ // INITIALIZE CONTEXT UTILITIES FOR COMPONENT FACTORY
69
+ // =============================================================================
1223
70
 
1224
- const safeTimeout = (fn, delay) => {
1225
- const timerId = dom.setTimeout(() => {
1226
- activeTimers.delete(timerId);
1227
- fn();
1228
- }, delay);
1229
- activeTimers.add(timerId);
1230
- return timerId;
1231
- };
71
+ // Inject DOM utilities into component factory to avoid circular dependencies
72
+ _setContextUtils({
73
+ el,
74
+ text,
75
+ list,
76
+ when,
77
+ on,
78
+ bind,
79
+ model
80
+ });
1232
81
 
1233
- const clearAllTimers = () => {
1234
- for (const timerId of activeTimers) {
1235
- dom.clearTimeout(timerId);
1236
- }
1237
- activeTimers.clear();
1238
- };
82
+ // =============================================================================
83
+ // NAMED EXPORTS
84
+ // =============================================================================
1239
85
 
1240
- // Register cleanup for all timers
1241
- onCleanup(clearAllTimers);
86
+ export {
87
+ // Selector parsing
88
+ parseSelector,
89
+ resolveSelector,
1242
90
 
1243
- effect(() => {
1244
- const show = typeof condition === 'function' ? condition() : condition.get();
91
+ // Configuration
92
+ configureDom,
93
+ getDomConfig,
94
+ clearSelectorCache,
95
+ getCacheMetrics,
96
+ resetCacheMetrics,
1245
97
 
1246
- if (isTransitioning) return;
98
+ // Element creation
99
+ el,
100
+ text,
1247
101
 
1248
- const template = show ? thenTemplate : elseTemplate;
102
+ // Reactive bindings
103
+ bind,
104
+ prop,
105
+ cls,
106
+ style,
107
+ on,
108
+ model,
1249
109
 
1250
- // Exit animation for current nodes
1251
- if (currentNodes.length > 0) {
1252
- isTransitioning = true;
1253
- const nodesToRemove = [...currentNodes];
1254
- currentNodes = [];
110
+ // List rendering
111
+ list,
112
+ computeLIS,
1255
113
 
1256
- for (const node of nodesToRemove) {
1257
- dom.addClass(node, exitClass);
1258
- }
114
+ // Conditional rendering
115
+ when,
116
+ match,
117
+ show,
1259
118
 
1260
- safeTimeout(() => {
1261
- for (const node of nodesToRemove) {
1262
- dom.removeNode(node);
1263
- }
1264
- isTransitioning = false;
119
+ // Lifecycle
120
+ onMount,
121
+ onUnmount,
122
+ mount,
123
+ component,
124
+ getMountContext,
125
+ setMountContext,
1265
126
 
1266
- // Render new content
1267
- if (template) {
1268
- const result = typeof template === 'function' ? template() : template;
1269
- if (result) {
1270
- const nodes = Array.isArray(result) ? result : [result];
1271
- const fragment = dom.createDocumentFragment();
1272
- for (const node of nodes) {
1273
- if (dom.isNode(node)) {
1274
- dom.addClass(node, enterClass);
1275
- dom.appendChild(fragment, node);
1276
- currentNodes.push(node);
1277
- safeTimeout(() => dom.removeClass(node, enterClass), duration);
1278
- }
1279
- }
1280
- const markerParent = dom.getParentNode(marker);
1281
- if (markerParent) {
1282
- dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
1283
- }
1284
- }
1285
- }
1286
- }, duration);
1287
- } else if (template) {
1288
- // No previous content, just render with enter animation
1289
- const result = typeof template === 'function' ? template() : template;
1290
- if (result) {
1291
- const nodes = Array.isArray(result) ? result : [result];
1292
- const fragment = dom.createDocumentFragment();
1293
- for (const node of nodes) {
1294
- if (dom.isNode(node)) {
1295
- dom.addClass(node, enterClass);
1296
- dom.appendChild(fragment, node);
1297
- currentNodes.push(node);
1298
- safeTimeout(() => dom.removeClass(node, enterClass), duration);
1299
- }
1300
- }
1301
- const markerParent = dom.getParentNode(marker);
1302
- if (markerParent) {
1303
- dom.insertBefore(markerParent, fragment, dom.getNextSibling(marker));
1304
- }
1305
- }
1306
- }
1307
- });
127
+ // Advanced features
128
+ portal,
129
+ errorBoundary,
130
+ transition,
131
+ whenTransition
132
+ };
1308
133
 
1309
- return container;
1310
- }
134
+ // =============================================================================
135
+ // DEFAULT EXPORT
136
+ // =============================================================================
1311
137
 
1312
138
  export default {
139
+ // Element creation
1313
140
  el,
1314
141
  text,
142
+
143
+ // Reactive bindings
1315
144
  bind,
1316
145
  prop,
1317
146
  cls,
1318
147
  style,
1319
148
  on,
149
+ model,
150
+
151
+ // List rendering
1320
152
  list,
153
+
154
+ // Conditional rendering
1321
155
  when,
1322
156
  match,
1323
- model,
157
+ show,
158
+
159
+ // Lifecycle
160
+ onMount,
161
+ onUnmount,
1324
162
  mount,
1325
163
  component,
164
+
165
+ // Selector parsing
1326
166
  parseSelector,
1327
- // New features
1328
- onMount,
1329
- onUnmount,
1330
- show,
167
+
168
+ // Advanced features
1331
169
  portal,
1332
170
  errorBoundary,
1333
171
  transition,
1334
172
  whenTransition,
173
+
1335
174
  // Configuration
1336
175
  configureDom,
1337
176
  getDomConfig,
1338
177
  clearSelectorCache,
178
+
1339
179
  // Diagnostics
1340
180
  getCacheMetrics,
1341
181
  resetCacheMetrics