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.
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Pulse DOM List Module
3
+ * Reactive list rendering with efficient keyed diffing using LIS algorithm
4
+ *
5
+ * @module dom-list
6
+ */
7
+
8
+ import { effect } from './pulse.js';
9
+ import { getAdapter } from './dom-adapter.js';
10
+
11
+ // =============================================================================
12
+ // LIS ALGORITHM
13
+ // =============================================================================
14
+
15
+ /**
16
+ * Compute Longest Increasing Subsequence indices
17
+ * Used to minimize DOM moves during list reconciliation
18
+ *
19
+ * @private
20
+ * @param {number[]} arr - Array of indices
21
+ * @returns {number[]} Indices of elements in the LIS
22
+ */
23
+ export function computeLIS(arr) {
24
+ const n = arr.length;
25
+ if (n === 0) return [];
26
+
27
+ // dp[i] = smallest tail of LIS of length i+1
28
+ const dp = [];
29
+ // parent[i] = index of previous element in LIS ending at i
30
+ const parent = new Array(n).fill(-1);
31
+ // indices[i] = index in original array of dp[i]
32
+ const indices = [];
33
+
34
+ for (let i = 0; i < n; i++) {
35
+ const val = arr[i];
36
+
37
+ // Binary search for position
38
+ let lo = 0, hi = dp.length;
39
+ while (lo < hi) {
40
+ const mid = (lo + hi) >> 1;
41
+ if (dp[mid] < val) lo = mid + 1;
42
+ else hi = mid;
43
+ }
44
+
45
+ if (lo === dp.length) {
46
+ dp.push(val);
47
+ indices.push(i);
48
+ } else {
49
+ dp[lo] = val;
50
+ indices[lo] = i;
51
+ }
52
+
53
+ parent[i] = lo > 0 ? indices[lo - 1] : -1;
54
+ }
55
+
56
+ // Reconstruct LIS indices
57
+ const lis = [];
58
+ let idx = indices[dp.length - 1];
59
+ while (idx !== -1) {
60
+ lis.push(idx);
61
+ idx = parent[idx];
62
+ }
63
+
64
+ return lis.reverse();
65
+ }
66
+
67
+ // =============================================================================
68
+ // LIST RENDERING
69
+ // =============================================================================
70
+
71
+ /**
72
+ * Create a reactive list with efficient keyed diffing
73
+ *
74
+ * LIST DIFFING ALGORITHM:
75
+ * -----------------------
76
+ * This uses a keyed reconciliation strategy to minimize DOM operations:
77
+ *
78
+ * 1. KEY EXTRACTION: Each item gets a unique key via keyFn (defaults to index)
79
+ * Good keys: item.id, item.uuid (stable across re-renders)
80
+ * Bad keys: array index (causes unnecessary re-renders on reorder)
81
+ *
82
+ * 2. RECONCILIATION PHASES:
83
+ * a) Build a map of new items by key
84
+ * b) For existing keys: reuse the DOM nodes (no re-creation)
85
+ * c) For removed keys: remove DOM nodes and run cleanup
86
+ * d) For new keys: batch create via DocumentFragment
87
+ *
88
+ * 3. REORDERING: Uses LIS (Longest Increasing Subsequence):
89
+ * - Computes which nodes are already in correct relative order
90
+ * - Only moves nodes NOT in the LIS (minimizes DOM operations)
91
+ * - New items are batched into DocumentFragment before insertion
92
+ *
93
+ * 4. BOUNDARY MARKERS: Uses comment nodes to track list boundaries:
94
+ * - startMarker: Insertion point for first item
95
+ * - endMarker: End boundary (not currently used but reserved)
96
+ *
97
+ * COMPLEXITY: O(n log n) for LIS + O(m) DOM moves where m = n - LIS length
98
+ * Best case (append only): O(n) with single DocumentFragment insert
99
+ *
100
+ * @param {Function|Pulse} getItems - Items source (reactive)
101
+ * @param {Function} template - (item, index) => Node | Node[]
102
+ * @param {Function} keyFn - (item, index) => key (default: index)
103
+ * @returns {DocumentFragment} Container fragment with reactive list
104
+ */
105
+ export function list(getItems, template, keyFn = (item, i) => i) {
106
+ const dom = getAdapter();
107
+ const container = dom.createDocumentFragment();
108
+ const startMarker = dom.createComment('list-start');
109
+ const endMarker = dom.createComment('list-end');
110
+
111
+ dom.appendChild(container, startMarker);
112
+ dom.appendChild(container, endMarker);
113
+
114
+ // Map: key -> { nodes: Node[], cleanup: Function, item: any }
115
+ let itemNodes = new Map();
116
+ let keyOrder = []; // Track order of keys for diffing
117
+
118
+ effect(() => {
119
+ const items = typeof getItems === 'function' ? getItems() : getItems.get();
120
+ const itemsArray = Array.isArray(items) ? items : Array.from(items);
121
+
122
+ const newKeys = [];
123
+ const newItemNodes = new Map();
124
+ const newItems = []; // Track new items for batched insertion
125
+
126
+ // Phase 1: Build map of new items by key
127
+ itemsArray.forEach((item, index) => {
128
+ const key = keyFn(item, index);
129
+ newKeys.push(key);
130
+
131
+ if (itemNodes.has(key)) {
132
+ // Reuse existing entry
133
+ newItemNodes.set(key, itemNodes.get(key));
134
+ } else {
135
+ // Mark as new (will batch create later)
136
+ newItems.push({ key, item, index });
137
+ }
138
+ });
139
+
140
+ // Phase 2: Batch create new nodes using DocumentFragment
141
+ if (newItems.length > 0) {
142
+ for (const { key, item, index } of newItems) {
143
+ const result = template(item, index);
144
+ const nodes = Array.isArray(result) ? result : [result];
145
+ newItemNodes.set(key, { nodes, cleanup: null, item });
146
+ }
147
+ }
148
+
149
+ // Phase 3: Remove items that are no longer present
150
+ for (const [key, entry] of itemNodes) {
151
+ if (!newItemNodes.has(key)) {
152
+ for (const node of entry.nodes) {
153
+ dom.removeNode(node);
154
+ }
155
+ if (entry.cleanup) entry.cleanup();
156
+ }
157
+ }
158
+
159
+ // Phase 4: Efficient reordering using LIS algorithm
160
+ // Build old position map for existing keys
161
+ const oldKeyIndex = new Map();
162
+ keyOrder.forEach((key, i) => oldKeyIndex.set(key, i));
163
+
164
+ // Get indices of existing items in old order
165
+ const existingIndices = [];
166
+ const existingKeys = [];
167
+ for (let i = 0; i < newKeys.length; i++) {
168
+ const key = newKeys[i];
169
+ if (oldKeyIndex.has(key)) {
170
+ existingIndices.push(oldKeyIndex.get(key));
171
+ existingKeys.push(key);
172
+ }
173
+ }
174
+
175
+ // Compute LIS - these nodes don't need to move
176
+ const lisIndices = new Set(computeLIS(existingIndices));
177
+ const stableKeys = new Set();
178
+ existingKeys.forEach((key, i) => {
179
+ if (lisIndices.has(i)) {
180
+ stableKeys.add(key);
181
+ }
182
+ });
183
+
184
+ // Phase 5: Position nodes with minimal DOM operations
185
+ const parent = dom.getParentNode(startMarker);
186
+ if (!parent) {
187
+ // Not yet in DOM, use simple append with DocumentFragment batch
188
+ const fragment = dom.createDocumentFragment();
189
+ for (const key of newKeys) {
190
+ const entry = newItemNodes.get(key);
191
+ for (const node of entry.nodes) {
192
+ dom.appendChild(fragment, node);
193
+ }
194
+ }
195
+ dom.insertBefore(container, fragment, endMarker);
196
+ } else {
197
+ // Optimized reordering: batch consecutive inserts using DocumentFragment
198
+ let prevNode = startMarker;
199
+
200
+ // Process items in new order
201
+ for (let i = 0; i < newKeys.length; i++) {
202
+ const key = newKeys[i];
203
+ const entry = newItemNodes.get(key);
204
+ const firstNode = entry.nodes[0];
205
+ const isNew = !oldKeyIndex.has(key);
206
+ const isStable = stableKeys.has(key);
207
+
208
+ // Check if node is already in correct position
209
+ const inPosition = dom.getNextSibling(prevNode) === firstNode;
210
+
211
+ if (inPosition && (isStable || isNew)) {
212
+ // Already in correct position, just advance
213
+ prevNode = entry.nodes[entry.nodes.length - 1];
214
+ } else {
215
+ // Collect consecutive items that need to be inserted at this position
216
+ const fragment = dom.createDocumentFragment();
217
+ let j = i;
218
+
219
+ while (j < newKeys.length) {
220
+ const k = newKeys[j];
221
+ const e = newItemNodes.get(k);
222
+ const f = e.nodes[0];
223
+ const n = !oldKeyIndex.has(k);
224
+ const s = stableKeys.has(k);
225
+
226
+ // If this item is already in position after prevNode, stop batching
227
+ if (j > i && dom.getNextSibling(prevNode) === f) {
228
+ break;
229
+ }
230
+
231
+ // Add to batch if it's new or needs to move
232
+ if (n || !s || dom.getNextSibling(prevNode) !== f) {
233
+ for (const node of e.nodes) {
234
+ dom.appendChild(fragment, node);
235
+ }
236
+ j++;
237
+ } else {
238
+ break;
239
+ }
240
+ }
241
+
242
+ // Insert the batch
243
+ if (dom.getFirstChild(fragment)) {
244
+ dom.insertBefore(parent, fragment, dom.getNextSibling(prevNode));
245
+ }
246
+
247
+ // Update prevNode to last inserted node
248
+ if (j > i) {
249
+ const lastEntry = newItemNodes.get(newKeys[j - 1]);
250
+ prevNode = lastEntry.nodes[lastEntry.nodes.length - 1];
251
+ i = j - 1; // Continue from where we left off
252
+ }
253
+ }
254
+ }
255
+ }
256
+
257
+ itemNodes = newItemNodes;
258
+ keyOrder = newKeys;
259
+ });
260
+
261
+ return container;
262
+ }
263
+
264
+ export default {
265
+ list,
266
+ computeLIS
267
+ };
@@ -0,0 +1,267 @@
1
+ /**
2
+ * Pulse DOM Selector Module
3
+ * CSS selector parsing with LRU caching
4
+ *
5
+ * @module dom-selector
6
+ */
7
+
8
+ import { loggers } from './logger.js';
9
+ import { LRUCache } from './lru-cache.js';
10
+ import { getAdapter } from './dom-adapter.js';
11
+
12
+ const log = loggers.dom;
13
+
14
+ // =============================================================================
15
+ // CONFIGURATION
16
+ // =============================================================================
17
+
18
+ /**
19
+ * @typedef {Object} DomConfig
20
+ * @property {number} [selectorCacheCapacity=500] - Max selectors to cache (0 = disabled)
21
+ * @property {boolean} [trackCacheMetrics=true] - Enable cache hit/miss tracking
22
+ */
23
+
24
+ /** @type {DomConfig} */
25
+ const config = {
26
+ selectorCacheCapacity: 500,
27
+ trackCacheMetrics: true
28
+ };
29
+
30
+ // =============================================================================
31
+ // SELECTOR CACHE
32
+ // =============================================================================
33
+ // LRU (Least Recently Used) cache for parseSelector results.
34
+ //
35
+ // Why LRU instead of Map?
36
+ // - Apps typically reuse the same selectors (e.g., 'div.container', 'button.primary')
37
+ // - Without eviction, memory grows unbounded in long-running apps
38
+ // - LRU keeps frequently-used selectors hot while evicting rare ones
39
+ //
40
+ // Default capacity: 500 selectors (configurable via configureDom())
41
+ // - Most apps use 50-200 unique selectors
42
+ // - 500 provides headroom for dynamic selectors without excessive memory
43
+ //
44
+ // Cache hit returns a shallow copy to prevent mutation of cached config
45
+ // Metrics tracking enabled for performance monitoring
46
+ let selectorCache = new LRUCache(config.selectorCacheCapacity, { trackMetrics: config.trackCacheMetrics });
47
+
48
+ /**
49
+ * Configure DOM module settings.
50
+ *
51
+ * Call this before using any DOM functions if you need non-default settings.
52
+ * Changing configuration clears the selector cache.
53
+ *
54
+ * @param {Partial<DomConfig>} options - Configuration options
55
+ * @returns {DomConfig} Current configuration after applying changes
56
+ *
57
+ * @example
58
+ * // Increase cache for large apps
59
+ * configureDom({ selectorCacheCapacity: 1000 });
60
+ *
61
+ * @example
62
+ * // Disable caching for debugging
63
+ * configureDom({ selectorCacheCapacity: 0 });
64
+ *
65
+ * @example
66
+ * // Disable metrics tracking in production
67
+ * configureDom({ trackCacheMetrics: false });
68
+ */
69
+ export function configureDom(options = {}) {
70
+ let cacheChanged = false;
71
+
72
+ if (options.selectorCacheCapacity !== undefined) {
73
+ config.selectorCacheCapacity = options.selectorCacheCapacity;
74
+ cacheChanged = true;
75
+ }
76
+
77
+ if (options.trackCacheMetrics !== undefined) {
78
+ config.trackCacheMetrics = options.trackCacheMetrics;
79
+ cacheChanged = true;
80
+ }
81
+
82
+ // Recreate cache if settings changed
83
+ if (cacheChanged) {
84
+ if (config.selectorCacheCapacity > 0) {
85
+ selectorCache = new LRUCache(config.selectorCacheCapacity, {
86
+ trackMetrics: config.trackCacheMetrics
87
+ });
88
+ } else {
89
+ // Disable caching with a null-like cache
90
+ selectorCache = {
91
+ get: () => undefined,
92
+ set: () => {},
93
+ clear: () => {},
94
+ getMetrics: () => ({ hits: 0, misses: 0, evictions: 0, hitRate: 0, size: 0, capacity: 0 }),
95
+ resetMetrics: () => {}
96
+ };
97
+ }
98
+ }
99
+
100
+ return { ...config };
101
+ }
102
+
103
+ /**
104
+ * Get current DOM configuration.
105
+ * @returns {DomConfig} Current configuration (copy)
106
+ */
107
+ export function getDomConfig() {
108
+ return { ...config };
109
+ }
110
+
111
+ /**
112
+ * Clear the selector cache.
113
+ * Useful for memory management or after significant DOM structure changes.
114
+ */
115
+ export function clearSelectorCache() {
116
+ selectorCache.clear();
117
+ }
118
+
119
+ /**
120
+ * Get selector cache performance metrics.
121
+ * Useful for debugging and performance tuning.
122
+ *
123
+ * @returns {{hits: number, misses: number, evictions: number, hitRate: number, size: number, capacity: number}}
124
+ * @example
125
+ * const stats = getCacheMetrics();
126
+ * console.log(`Cache hit rate: ${(stats.hitRate * 100).toFixed(1)}%`);
127
+ * console.log(`Cache size: ${stats.size}/${stats.capacity}`);
128
+ */
129
+ export function getCacheMetrics() {
130
+ return selectorCache.getMetrics();
131
+ }
132
+
133
+ /**
134
+ * Reset cache metrics counters.
135
+ * Useful for measuring performance over specific time periods.
136
+ */
137
+ export function resetCacheMetrics() {
138
+ selectorCache.resetMetrics();
139
+ }
140
+
141
+ /**
142
+ * Resolve a selector string or element to a DOM element
143
+ * @param {string|HTMLElement} target - CSS selector or DOM element
144
+ * @param {string} context - Context name for error messages
145
+ * @returns {{element: HTMLElement|null, selector: string}} Resolved element and original selector
146
+ */
147
+ export function resolveSelector(target, context = 'target') {
148
+ if (typeof target === 'string') {
149
+ const dom = getAdapter();
150
+ const element = dom.querySelector(target);
151
+ return { element, selector: target };
152
+ }
153
+ return { element: target, selector: '(element)' };
154
+ }
155
+
156
+ /**
157
+ * Parse a CSS selector-like string into element configuration
158
+ * Results are cached for performance using LRU cache.
159
+ *
160
+ * Supported syntax:
161
+ * - Tag: `div`, `span`, `custom-element`
162
+ * - ID: `#app`, `#my-id`, `#_private`
163
+ * - Classes: `.class`, `.my-class`, `.-modifier`
164
+ * - Attributes: `[attr]`, `[attr=value]`, `[attr="quoted value"]`, `[attr='single quoted']`
165
+ *
166
+ * Examples:
167
+ * "div" -> { tag: "div" }
168
+ * "#app" -> { tag: "div", id: "app" }
169
+ * ".container" -> { tag: "div", classes: ["container"] }
170
+ * "button.primary.large" -> { tag: "button", classes: ["primary", "large"] }
171
+ * "input[type=text][placeholder=Name]" -> { tag: "input", attrs: { type: "text", placeholder: "Name" } }
172
+ * "div[data-id=\"complex-123\"]" -> { tag: "div", attrs: { "data-id": "complex-123" } }
173
+ *
174
+ * @param {string} selector - CSS selector-like string
175
+ * @returns {{tag: string, id: string|null, classes: string[], attrs: Object}} Parsed configuration
176
+ */
177
+ export function parseSelector(selector) {
178
+ if (!selector || selector === '') {
179
+ return { tag: 'div', id: null, classes: [], attrs: {} };
180
+ }
181
+
182
+ // Check cache first
183
+ const cached = selectorCache.get(selector);
184
+ if (cached) {
185
+ // Return a shallow copy to prevent mutation
186
+ return {
187
+ tag: cached.tag,
188
+ id: cached.id,
189
+ classes: [...cached.classes],
190
+ attrs: { ...cached.attrs }
191
+ };
192
+ }
193
+
194
+ const config = {
195
+ tag: 'div',
196
+ id: null,
197
+ classes: [],
198
+ attrs: {}
199
+ };
200
+
201
+ let remaining = selector;
202
+
203
+ // Match tag name at the start
204
+ const tagMatch = remaining.match(/^([a-zA-Z][a-zA-Z0-9-]*)/);
205
+ if (tagMatch) {
206
+ config.tag = tagMatch[1];
207
+ remaining = remaining.slice(tagMatch[0].length);
208
+ }
209
+
210
+ // Match ID (supports starting with letter, underscore, or hyphen followed by valid chars)
211
+ const idMatch = remaining.match(/#([a-zA-Z_-][a-zA-Z0-9-_]*)/);
212
+ if (idMatch) {
213
+ config.id = idMatch[1];
214
+ remaining = remaining.replace(idMatch[0], '');
215
+ }
216
+
217
+ // Match classes (supports starting with letter, underscore, or hyphen)
218
+ const classMatches = remaining.matchAll(/\.([a-zA-Z_-][a-zA-Z0-9-_]*)/g);
219
+ for (const match of classMatches) {
220
+ config.classes.push(match[1]);
221
+ }
222
+
223
+ // Match attributes - improved regex handles quoted values with special characters
224
+ // Matches: [attr], [attr=value], [attr="quoted value"], [attr='quoted value']
225
+ const attrRegex = /\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"([^"]*)"|'([^']*)'|([^\]]*)))?\]/g;
226
+ const attrMatches = remaining.matchAll(attrRegex);
227
+ for (const match of attrMatches) {
228
+ const key = match[1];
229
+ // Value can be in match[2] (double-quoted), match[3] (single-quoted), or match[4] (unquoted)
230
+ const value = match[2] ?? match[3] ?? match[4] ?? '';
231
+ config.attrs[key] = value;
232
+ }
233
+
234
+ // Validate: check for unparsed content (malformed selector parts)
235
+ // Remove all parsed parts to see if anything remains
236
+ let unparsed = remaining
237
+ .replace(/#[a-zA-Z_-][a-zA-Z0-9-_]*/g, '') // Remove IDs
238
+ .replace(/\.[a-zA-Z_-][a-zA-Z0-9-_]*/g, '') // Remove classes
239
+ .replace(/\[([a-zA-Z_][a-zA-Z0-9-_]*)(?:=(?:"[^"]*"|'[^']*'|[^\]]*))?\]/g, '') // Remove attrs
240
+ .trim();
241
+
242
+ if (unparsed) {
243
+ log.warn(`Selector "${selector}" contains unrecognized parts: "${unparsed}". ` +
244
+ 'Supported syntax: tag#id.class[attr=value]');
245
+ }
246
+
247
+ // Cache the result (LRU cache handles eviction automatically)
248
+ selectorCache.set(selector, config);
249
+
250
+ // Return a copy
251
+ return {
252
+ tag: config.tag,
253
+ id: config.id,
254
+ classes: [...config.classes],
255
+ attrs: { ...config.attrs }
256
+ };
257
+ }
258
+
259
+ export default {
260
+ parseSelector,
261
+ resolveSelector,
262
+ configureDom,
263
+ getDomConfig,
264
+ clearSelectorCache,
265
+ getCacheMetrics,
266
+ resetCacheMetrics
267
+ };