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/README.md +78 -392
- package/cli/dev.js +14 -0
- package/cli/docs-test.js +633 -0
- package/cli/index.js +313 -31
- package/cli/lint.js +13 -4
- package/cli/logger.js +32 -4
- package/cli/release.js +50 -20
- package/core/errors.js +2 -572
- package/package.json +11 -4
- package/runtime/dom-advanced.js +357 -0
- package/runtime/dom-binding.js +230 -0
- package/runtime/dom-conditional.js +133 -0
- package/runtime/dom-element.js +142 -0
- package/runtime/dom-lifecycle.js +178 -0
- package/runtime/dom-list.js +267 -0
- package/runtime/dom-selector.js +267 -0
- package/runtime/dom.js +119 -1279
- package/runtime/errors.js +575 -0
- package/runtime/form.js +417 -22
- package/runtime/native.js +398 -52
- package/runtime/pulse.js +1 -1
- package/runtime/router.js +6 -5
- package/runtime/store.js +81 -6
- package/types/async.d.ts +310 -0
- package/types/form.d.ts +378 -0
- package/types/index.d.ts +44 -0
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
|
-
//
|
|
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
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
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
|
-
|
|
1196
|
-
|
|
41
|
+
// Core element creation
|
|
42
|
+
import { el, text } from './dom-element.js';
|
|
1197
43
|
|
|
1198
|
-
|
|
1199
|
-
|
|
44
|
+
// Reactive bindings
|
|
45
|
+
import { bind, prop, cls, style, on, model } from './dom-binding.js';
|
|
1200
46
|
|
|
1201
|
-
|
|
1202
|
-
}
|
|
47
|
+
// List rendering
|
|
48
|
+
import { list, computeLIS } from './dom-list.js';
|
|
1203
49
|
|
|
1204
|
-
|
|
1205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1219
|
-
|
|
64
|
+
// Advanced features
|
|
65
|
+
import { portal, errorBoundary, transition, whenTransition } from './dom-advanced.js';
|
|
1220
66
|
|
|
1221
|
-
|
|
1222
|
-
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// INITIALIZE CONTEXT UTILITIES FOR COMPONENT FACTORY
|
|
69
|
+
// =============================================================================
|
|
1223
70
|
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
}
|
|
1237
|
-
activeTimers.clear();
|
|
1238
|
-
};
|
|
82
|
+
// =============================================================================
|
|
83
|
+
// NAMED EXPORTS
|
|
84
|
+
// =============================================================================
|
|
1239
85
|
|
|
1240
|
-
|
|
1241
|
-
|
|
86
|
+
export {
|
|
87
|
+
// Selector parsing
|
|
88
|
+
parseSelector,
|
|
89
|
+
resolveSelector,
|
|
1242
90
|
|
|
1243
|
-
|
|
1244
|
-
|
|
91
|
+
// Configuration
|
|
92
|
+
configureDom,
|
|
93
|
+
getDomConfig,
|
|
94
|
+
clearSelectorCache,
|
|
95
|
+
getCacheMetrics,
|
|
96
|
+
resetCacheMetrics,
|
|
1245
97
|
|
|
1246
|
-
|
|
98
|
+
// Element creation
|
|
99
|
+
el,
|
|
100
|
+
text,
|
|
1247
101
|
|
|
1248
|
-
|
|
102
|
+
// Reactive bindings
|
|
103
|
+
bind,
|
|
104
|
+
prop,
|
|
105
|
+
cls,
|
|
106
|
+
style,
|
|
107
|
+
on,
|
|
108
|
+
model,
|
|
1249
109
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
const nodesToRemove = [...currentNodes];
|
|
1254
|
-
currentNodes = [];
|
|
110
|
+
// List rendering
|
|
111
|
+
list,
|
|
112
|
+
computeLIS,
|
|
1255
113
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
114
|
+
// Conditional rendering
|
|
115
|
+
when,
|
|
116
|
+
match,
|
|
117
|
+
show,
|
|
1259
118
|
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
119
|
+
// Lifecycle
|
|
120
|
+
onMount,
|
|
121
|
+
onUnmount,
|
|
122
|
+
mount,
|
|
123
|
+
component,
|
|
124
|
+
getMountContext,
|
|
125
|
+
setMountContext,
|
|
1265
126
|
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
+
show,
|
|
158
|
+
|
|
159
|
+
// Lifecycle
|
|
160
|
+
onMount,
|
|
161
|
+
onUnmount,
|
|
1324
162
|
mount,
|
|
1325
163
|
component,
|
|
164
|
+
|
|
165
|
+
// Selector parsing
|
|
1326
166
|
parseSelector,
|
|
1327
|
-
|
|
1328
|
-
|
|
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
|