pulse-js-framework 1.7.38 → 1.8.1
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/package.json +6 -1
- package/runtime/dom-adapter.js +4 -0
- package/runtime/dom-event-delegate.js +248 -0
- package/runtime/dom-list.js +59 -1
- package/runtime/dom-recycle.js +230 -0
- package/runtime/dom-virtual-list.js +276 -0
- package/runtime/dom.js +35 -2
- package/runtime/pulse.js +25 -5
- package/types/dom.d.ts +133 -0
- package/types/index.d.ts +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pulse-js-framework",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.8.1",
|
|
4
4
|
"description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -179,11 +179,16 @@
|
|
|
179
179
|
"test:esbuild-plugin": "node test/esbuild-plugin.test.js",
|
|
180
180
|
"test:parcel-plugin": "node test/parcel-plugin.test.js",
|
|
181
181
|
"test:swc-plugin": "node test/swc-plugin.test.js",
|
|
182
|
+
"test:dom-recycle": "node test/dom-recycle.test.js",
|
|
183
|
+
"test:dom-virtual-list": "node test/dom-virtual-list.test.js",
|
|
184
|
+
"test:dom-event-delegate": "node test/dom-event-delegate.test.js",
|
|
182
185
|
"test:dom-binding": "node test/dom-binding.test.js",
|
|
183
186
|
"test:interceptor-manager": "node test/interceptor-manager.test.js",
|
|
184
187
|
"test:vite-plugin": "node --test test/vite-plugin.test.js",
|
|
185
188
|
"test:memory-cleanup": "node --test test/memory-cleanup.test.js",
|
|
186
189
|
"test:dev-server": "node --test test/dev-server.test.js",
|
|
190
|
+
"bench": "node benchmarks/index.js",
|
|
191
|
+
"bench:json": "node benchmarks/index.js --json",
|
|
187
192
|
"build:netlify": "node scripts/build-netlify.js",
|
|
188
193
|
"version": "node scripts/sync-version.js",
|
|
189
194
|
"docs": "node cli/index.js dev docs"
|
package/runtime/dom-adapter.js
CHANGED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse DOM Event Delegation
|
|
3
|
+
*
|
|
4
|
+
* Provides event delegation for list rendering, placing a single listener
|
|
5
|
+
* on the parent element instead of individual listeners on each item.
|
|
6
|
+
*
|
|
7
|
+
* Related: ADR-0002, list() in dom-list.js
|
|
8
|
+
*
|
|
9
|
+
* @module runtime/dom-event-delegate
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { getAdapter } from './dom-adapter.js';
|
|
13
|
+
import { list } from './dom-list.js';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Constants
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Events that do not bubble and require capture mode for delegation.
|
|
21
|
+
* @type {Set<string>}
|
|
22
|
+
*/
|
|
23
|
+
const NON_BUBBLING = new Set([
|
|
24
|
+
'focus', 'blur', 'scroll',
|
|
25
|
+
'mouseenter', 'mouseleave',
|
|
26
|
+
'pointerenter', 'pointerleave',
|
|
27
|
+
'load', 'unload', 'error'
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Data attribute used to mark list items with their key.
|
|
32
|
+
* @type {string}
|
|
33
|
+
*/
|
|
34
|
+
const KEY_ATTR = 'data-pulse-key';
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Low-level Delegation
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Attach a delegated event listener to a parent element.
|
|
42
|
+
* Events from descendants matching the selector bubble up to the parent.
|
|
43
|
+
*
|
|
44
|
+
* @param {Element} parent - Container element to listen on
|
|
45
|
+
* @param {string} eventType - Event type (e.g., 'click', 'input')
|
|
46
|
+
* @param {string} selector - CSS selector to match against (uses closest())
|
|
47
|
+
* @param {Function} handler - (event, matchedElement) => void
|
|
48
|
+
* @returns {Function} Cleanup function to remove the listener
|
|
49
|
+
*
|
|
50
|
+
* @example
|
|
51
|
+
* const cleanup = delegate(listContainer, 'click', '[data-key]', (event, el) => {
|
|
52
|
+
* console.log('Clicked:', el.dataset.key);
|
|
53
|
+
* });
|
|
54
|
+
* // Later: cleanup();
|
|
55
|
+
*/
|
|
56
|
+
export function delegate(parent, eventType, selector, handler) {
|
|
57
|
+
const dom = getAdapter();
|
|
58
|
+
const useCapture = NON_BUBBLING.has(eventType);
|
|
59
|
+
|
|
60
|
+
const listener = (event) => {
|
|
61
|
+
let target = event.target;
|
|
62
|
+
|
|
63
|
+
// Walk up from target to parent, looking for a match
|
|
64
|
+
while (target && target !== parent) {
|
|
65
|
+
if (matchesSelector(target, selector, dom)) {
|
|
66
|
+
handler(event, target);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
target = dom.getParentNode(target);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
dom.addEventListener(parent, eventType, listener, useCapture);
|
|
74
|
+
|
|
75
|
+
return () => {
|
|
76
|
+
dom.removeEventListener(parent, eventType, listener, useCapture);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if an element matches a CSS selector.
|
|
82
|
+
* Uses closest() for real elements, falls back to attribute check for mocks.
|
|
83
|
+
*
|
|
84
|
+
* @param {Element} element - Element to test
|
|
85
|
+
* @param {string} selector - CSS selector
|
|
86
|
+
* @param {DOMAdapter} dom - DOM adapter
|
|
87
|
+
* @returns {boolean} Whether element matches
|
|
88
|
+
* @private
|
|
89
|
+
*/
|
|
90
|
+
function matchesSelector(element, selector, dom) {
|
|
91
|
+
// Use native matches if available
|
|
92
|
+
if (typeof element.matches === 'function') {
|
|
93
|
+
return element.matches(selector);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Fallback for MockDOMAdapter: simple attribute selector matching
|
|
97
|
+
if (selector.startsWith('[') && selector.endsWith(']')) {
|
|
98
|
+
const inner = selector.slice(1, -1);
|
|
99
|
+
const eqIdx = inner.indexOf('=');
|
|
100
|
+
if (eqIdx === -1) {
|
|
101
|
+
return dom.getAttribute(element, inner) !== null;
|
|
102
|
+
}
|
|
103
|
+
const attrName = inner.slice(0, eqIdx);
|
|
104
|
+
const attrValue = inner.slice(eqIdx + 1).replace(/^["']|["']$/g, '');
|
|
105
|
+
return dom.getAttribute(element, attrName) === attrValue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ============================================================================
|
|
112
|
+
// Delegated List
|
|
113
|
+
// ============================================================================
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Create a reactive list with delegated event handlers.
|
|
117
|
+
* Instead of attaching listeners to each item, a single delegated listener
|
|
118
|
+
* is placed on the parent container for each event type.
|
|
119
|
+
*
|
|
120
|
+
* @param {Function|Pulse} getItems - Items source (reactive)
|
|
121
|
+
* @param {Function} template - (item, index) => Node | Node[]
|
|
122
|
+
* @param {Function} keyFn - (item, index) => key
|
|
123
|
+
* @param {Object} [options] - Configuration
|
|
124
|
+
* @param {Object} [options.on] - Event handlers: { eventType: (event, item, index) => void }
|
|
125
|
+
* @param {boolean} [options.recycle] - Enable element recycling
|
|
126
|
+
* @returns {DocumentFragment} Reactive list fragment
|
|
127
|
+
*
|
|
128
|
+
* @example
|
|
129
|
+
* const fragment = delegatedList(
|
|
130
|
+
* () => items.get(),
|
|
131
|
+
* (item, index) => el('li', item.name),
|
|
132
|
+
* (item) => item.id,
|
|
133
|
+
* {
|
|
134
|
+
* on: {
|
|
135
|
+
* click: (event, item, index) => selectItem(item),
|
|
136
|
+
* dblclick: (event, item, index) => editItem(item)
|
|
137
|
+
* }
|
|
138
|
+
* }
|
|
139
|
+
* );
|
|
140
|
+
*/
|
|
141
|
+
export function delegatedList(getItems, template, keyFn, options = {}) {
|
|
142
|
+
const { on: handlers = {}, ...listOptions } = options;
|
|
143
|
+
const dom = getAdapter();
|
|
144
|
+
|
|
145
|
+
// Internal map: key -> { item, index }
|
|
146
|
+
const itemMap = new Map();
|
|
147
|
+
|
|
148
|
+
// Track active keys per render cycle to clean stale itemMap entries
|
|
149
|
+
let activeKeys = new Set();
|
|
150
|
+
|
|
151
|
+
// Wrap template to add data-pulse-key attribute
|
|
152
|
+
const wrappedTemplate = (item, index) => {
|
|
153
|
+
const rawKey = keyFn(item, index);
|
|
154
|
+
// Security: ensure key is a string or number to prevent collisions
|
|
155
|
+
// Objects would all stringify to "[object Object]"
|
|
156
|
+
const key = (typeof rawKey === 'string' || typeof rawKey === 'number')
|
|
157
|
+
? String(rawKey)
|
|
158
|
+
: String(index);
|
|
159
|
+
const node = template(item, index);
|
|
160
|
+
const root = Array.isArray(node) ? node[0] : node;
|
|
161
|
+
|
|
162
|
+
if (root && dom.isElement(root)) {
|
|
163
|
+
dom.setAttribute(root, KEY_ATTR, key);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
itemMap.set(key, { item, index });
|
|
167
|
+
activeKeys.add(key);
|
|
168
|
+
return node;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Wrap getItems to track render cycles and prune stale entries
|
|
172
|
+
const wrappedGetItems = typeof getItems === 'function'
|
|
173
|
+
? () => {
|
|
174
|
+
activeKeys = new Set();
|
|
175
|
+
const result = getItems();
|
|
176
|
+
// Schedule cleanup after current effect cycle completes
|
|
177
|
+
if (typeof queueMicrotask === 'function') {
|
|
178
|
+
queueMicrotask(() => {
|
|
179
|
+
for (const key of itemMap.keys()) {
|
|
180
|
+
if (!activeKeys.has(key)) {
|
|
181
|
+
itemMap.delete(key);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
: getItems; // Pulse source — template calls track keys directly
|
|
189
|
+
|
|
190
|
+
// Create list with wrapped template
|
|
191
|
+
const fragment = list(wrappedGetItems, wrappedTemplate, keyFn, listOptions);
|
|
192
|
+
|
|
193
|
+
// Set up delegation on the fragment's parent when it's mounted
|
|
194
|
+
const cleanups = [];
|
|
195
|
+
let delegationSetUp = false;
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Set up delegation on a parent element.
|
|
199
|
+
* Called lazily when the fragment is attached to the DOM.
|
|
200
|
+
* @param {Element} parent - Parent element
|
|
201
|
+
* @private
|
|
202
|
+
*/
|
|
203
|
+
function setupDelegation(parent) {
|
|
204
|
+
if (delegationSetUp) return;
|
|
205
|
+
delegationSetUp = true;
|
|
206
|
+
|
|
207
|
+
for (const [eventType, handler] of Object.entries(handlers)) {
|
|
208
|
+
const cleanup = delegate(parent, eventType, `[${KEY_ATTR}]`, (event, matchedEl) => {
|
|
209
|
+
const key = dom.getAttribute(matchedEl, KEY_ATTR);
|
|
210
|
+
const entry = itemMap.get(key);
|
|
211
|
+
if (entry) {
|
|
212
|
+
handler(event, entry.item, entry.index);
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
cleanups.push(cleanup);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Observe when fragment gets a parent by using a MutationObserver-like approach.
|
|
220
|
+
// Since fragments move to a parent on insertion, we check after microtask.
|
|
221
|
+
if (typeof queueMicrotask === 'function') {
|
|
222
|
+
queueMicrotask(() => {
|
|
223
|
+
// Find the parent by looking at the fragment's first child's parent
|
|
224
|
+
const firstChild = dom.getFirstChild(fragment);
|
|
225
|
+
if (firstChild) {
|
|
226
|
+
const parent = dom.getParentNode(firstChild);
|
|
227
|
+
if (parent) {
|
|
228
|
+
setupDelegation(parent);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Attach setup helper for manual use
|
|
235
|
+
fragment._setupDelegation = setupDelegation;
|
|
236
|
+
fragment._cleanupDelegation = () => {
|
|
237
|
+
for (const cleanup of cleanups) cleanup();
|
|
238
|
+
cleanups.length = 0;
|
|
239
|
+
delegationSetUp = false;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
return fragment;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export default {
|
|
246
|
+
delegate,
|
|
247
|
+
delegatedList
|
|
248
|
+
};
|
package/runtime/dom-list.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { effect } from './pulse.js';
|
|
9
9
|
import { getAdapter } from './dom-adapter.js';
|
|
10
|
+
import { getPool } from './dom-recycle.js';
|
|
10
11
|
|
|
11
12
|
// =============================================================================
|
|
12
13
|
// LIS ALGORITHM
|
|
@@ -100,10 +101,13 @@ export function computeLIS(arr) {
|
|
|
100
101
|
* @param {Function|Pulse} getItems - Items source (reactive)
|
|
101
102
|
* @param {Function} template - (item, index) => Node | Node[]
|
|
102
103
|
* @param {Function} keyFn - (item, index) => key (default: index)
|
|
104
|
+
* @param {Object} [options] - Optional configuration
|
|
105
|
+
* @param {boolean} [options.recycle=false] - Enable element recycling via pool
|
|
103
106
|
* @returns {DocumentFragment} Container fragment with reactive list
|
|
104
107
|
*/
|
|
105
|
-
export function list(getItems, template, keyFn = (item, i) => i) {
|
|
108
|
+
export function list(getItems, template, keyFn = (item, i) => i, options = {}) {
|
|
106
109
|
const dom = getAdapter();
|
|
110
|
+
const pool = options.recycle ? getPool() : null;
|
|
107
111
|
const container = dom.createDocumentFragment();
|
|
108
112
|
const startMarker = dom.createComment('list-start');
|
|
109
113
|
const endMarker = dom.createComment('list-end');
|
|
@@ -138,10 +142,60 @@ export function list(getItems, template, keyFn = (item, i) => i) {
|
|
|
138
142
|
});
|
|
139
143
|
|
|
140
144
|
// Phase 2: Batch create new nodes using DocumentFragment
|
|
145
|
+
// When recycling is enabled, try to acquire root elements from the pool
|
|
146
|
+
// before falling back to createElement
|
|
141
147
|
if (newItems.length > 0) {
|
|
142
148
|
for (const { key, item, index } of newItems) {
|
|
143
149
|
const result = template(item, index);
|
|
144
150
|
const nodes = Array.isArray(result) ? result : [result];
|
|
151
|
+
|
|
152
|
+
// Pool acquire: if the root node is an element and pool has a matching
|
|
153
|
+
// tag, transfer children/attributes from template result to recycled element
|
|
154
|
+
if (pool && nodes.length === 1 && dom.isElement(nodes[0])) {
|
|
155
|
+
const original = nodes[0];
|
|
156
|
+
const tagName = (original.tagName || original.nodeName || '').toLowerCase();
|
|
157
|
+
if (tagName) {
|
|
158
|
+
const recycled = pool.acquire(tagName);
|
|
159
|
+
// Only use recycled element if it came from the pool (not freshly created)
|
|
160
|
+
// Pool.acquire creates new if empty, so always returns a valid element
|
|
161
|
+
if (recycled !== original) {
|
|
162
|
+
// Transfer attributes (skip event handler attributes for security)
|
|
163
|
+
if (original.attributes) {
|
|
164
|
+
const attrs = original.attributes;
|
|
165
|
+
for (let a = 0; a < attrs.length; a++) {
|
|
166
|
+
const attrName = attrs[a].name;
|
|
167
|
+
// Security: skip inline event handlers (onclick, onerror, etc.)
|
|
168
|
+
if (attrName.length > 2 && attrName.charCodeAt(0) === 111 &&
|
|
169
|
+
attrName.charCodeAt(1) === 110 && attrName.charCodeAt(2) > 96) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
dom.setAttribute(recycled, attrName, attrs[a].value);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Transfer children
|
|
176
|
+
let child = dom.getFirstChild(original);
|
|
177
|
+
while (child) {
|
|
178
|
+
const next = dom.getNextSibling(child);
|
|
179
|
+
dom.appendChild(recycled, child);
|
|
180
|
+
child = next;
|
|
181
|
+
}
|
|
182
|
+
// Transfer event listeners if tracked
|
|
183
|
+
if (original._eventListeners) {
|
|
184
|
+
recycled._eventListeners = original._eventListeners;
|
|
185
|
+
}
|
|
186
|
+
// Transfer inline styles
|
|
187
|
+
if (original.style && original.style.cssText) {
|
|
188
|
+
recycled.style.cssText = original.style.cssText;
|
|
189
|
+
}
|
|
190
|
+
// Transfer className
|
|
191
|
+
if (original.className) {
|
|
192
|
+
recycled.className = original.className;
|
|
193
|
+
}
|
|
194
|
+
nodes[0] = recycled;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
145
199
|
newItemNodes.set(key, { nodes, cleanup: null, item });
|
|
146
200
|
}
|
|
147
201
|
}
|
|
@@ -150,6 +204,10 @@ export function list(getItems, template, keyFn = (item, i) => i) {
|
|
|
150
204
|
for (const [key, entry] of itemNodes) {
|
|
151
205
|
if (!newItemNodes.has(key)) {
|
|
152
206
|
for (const node of entry.nodes) {
|
|
207
|
+
// Release to recycling pool before removing (if enabled)
|
|
208
|
+
if (pool && dom.isElement(node)) {
|
|
209
|
+
pool.release(node);
|
|
210
|
+
}
|
|
153
211
|
dom.removeNode(node);
|
|
154
212
|
}
|
|
155
213
|
if (entry.cleanup) entry.cleanup();
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse DOM Element Recycling Pool
|
|
3
|
+
*
|
|
4
|
+
* Pools detached DOM elements by tag name for reuse, avoiding expensive
|
|
5
|
+
* createElement() calls during list reconciliation.
|
|
6
|
+
*
|
|
7
|
+
* Used by: list() when `recycle: true` option is set
|
|
8
|
+
* Related: ADR-0004, virtual scrolling (ADR-0003)
|
|
9
|
+
*
|
|
10
|
+
* @module runtime/dom-recycle
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { getAdapter } from './dom-adapter.js';
|
|
14
|
+
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Constants & Configuration
|
|
17
|
+
// ============================================================================
|
|
18
|
+
|
|
19
|
+
const DEFAULT_OPTIONS = {
|
|
20
|
+
maxPerTag: 50,
|
|
21
|
+
maxTotal: 200,
|
|
22
|
+
resetOnRecycle: true
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Element Reset
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Reset an element to a clean state before pooling.
|
|
31
|
+
* Removes attributes, children, styles, and event listener references.
|
|
32
|
+
*
|
|
33
|
+
* @param {Element} element - Element to reset
|
|
34
|
+
* @param {DOMAdapter} dom - DOM adapter
|
|
35
|
+
* @private
|
|
36
|
+
*/
|
|
37
|
+
function resetElement(element, dom) {
|
|
38
|
+
// 1. Clear event listener tracking (if any)
|
|
39
|
+
if (element._eventListeners) {
|
|
40
|
+
element._eventListeners.clear();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// 2. Remove all attributes
|
|
44
|
+
if (element.attributes) {
|
|
45
|
+
const attrs = element.attributes;
|
|
46
|
+
while (attrs.length > 0) {
|
|
47
|
+
dom.removeAttribute(element, attrs[0].name);
|
|
48
|
+
}
|
|
49
|
+
} else if (typeof element.getAttributeNames === 'function') {
|
|
50
|
+
for (const name of element.getAttributeNames()) {
|
|
51
|
+
dom.removeAttribute(element, name);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. Clear content and styles
|
|
56
|
+
dom.setTextContent(element, '');
|
|
57
|
+
if (element.className !== undefined) {
|
|
58
|
+
element.className = '';
|
|
59
|
+
}
|
|
60
|
+
if (element.style && typeof element.style === 'object') {
|
|
61
|
+
element.style.cssText = '';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 4. Remove child nodes
|
|
65
|
+
let child = dom.getFirstChild(element);
|
|
66
|
+
while (child) {
|
|
67
|
+
dom.removeNode(child);
|
|
68
|
+
child = dom.getFirstChild(element);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ============================================================================
|
|
73
|
+
// Element Pool
|
|
74
|
+
// ============================================================================
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Create an element recycling pool.
|
|
78
|
+
*
|
|
79
|
+
* @param {Object} [options] - Pool configuration
|
|
80
|
+
* @param {number} [options.maxPerTag=50] - Max recycled elements per tag name
|
|
81
|
+
* @param {number} [options.maxTotal=200] - Max total recycled elements
|
|
82
|
+
* @param {boolean} [options.resetOnRecycle=true] - Reset attributes/styles on release
|
|
83
|
+
* @returns {ElementPool} Pool instance
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* const pool = createElementPool({ maxPerTag: 50, maxTotal: 200 });
|
|
87
|
+
* const li = pool.acquire('li'); // Reuses from pool or creates new
|
|
88
|
+
* pool.release(li); // Returns to pool for reuse
|
|
89
|
+
*/
|
|
90
|
+
export function createElementPool(options = {}) {
|
|
91
|
+
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
92
|
+
const dom = getAdapter();
|
|
93
|
+
|
|
94
|
+
/** @type {Map<string, Element[]>} */
|
|
95
|
+
const pool = new Map();
|
|
96
|
+
let totalSize = 0;
|
|
97
|
+
let hits = 0;
|
|
98
|
+
let misses = 0;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
/**
|
|
102
|
+
* Acquire an element from the pool or create a new one.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} tagName - Tag name (e.g., 'li', 'div')
|
|
105
|
+
* @returns {Element} A clean element ready for use
|
|
106
|
+
*/
|
|
107
|
+
acquire(tagName) {
|
|
108
|
+
const tag = tagName.toLowerCase();
|
|
109
|
+
const bucket = pool.get(tag);
|
|
110
|
+
|
|
111
|
+
if (bucket && bucket.length > 0) {
|
|
112
|
+
hits++;
|
|
113
|
+
totalSize--;
|
|
114
|
+
return bucket.pop();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
misses++;
|
|
118
|
+
return dom.createElement(tag);
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Release an element back to the pool for reuse.
|
|
123
|
+
* The element is reset (attributes, children, styles cleared) before pooling.
|
|
124
|
+
*
|
|
125
|
+
* @param {Element} element - Element to release
|
|
126
|
+
* @returns {boolean} true if element was pooled, false if pool is full
|
|
127
|
+
*/
|
|
128
|
+
release(element) {
|
|
129
|
+
if (!dom.isElement(element)) return false;
|
|
130
|
+
|
|
131
|
+
const tag = (element.tagName || element.nodeName || '').toLowerCase();
|
|
132
|
+
if (!tag) return false;
|
|
133
|
+
|
|
134
|
+
// Check pool limits
|
|
135
|
+
if (totalSize >= config.maxTotal) return false;
|
|
136
|
+
|
|
137
|
+
let bucket = pool.get(tag);
|
|
138
|
+
if (!bucket) {
|
|
139
|
+
bucket = [];
|
|
140
|
+
pool.set(tag, bucket);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (bucket.length >= config.maxPerTag) return false;
|
|
144
|
+
|
|
145
|
+
// Reset element before pooling
|
|
146
|
+
if (config.resetOnRecycle) {
|
|
147
|
+
resetElement(element, dom);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
bucket.push(element);
|
|
151
|
+
totalSize++;
|
|
152
|
+
return true;
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get pool statistics.
|
|
157
|
+
*
|
|
158
|
+
* @returns {Object} Pool stats
|
|
159
|
+
*/
|
|
160
|
+
stats() {
|
|
161
|
+
const total = hits + misses;
|
|
162
|
+
return {
|
|
163
|
+
size: totalSize,
|
|
164
|
+
hits,
|
|
165
|
+
misses,
|
|
166
|
+
hitRate: total > 0 ? Math.round((hits / total) * 1000) / 1000 : 0
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Clear all pooled elements.
|
|
172
|
+
*/
|
|
173
|
+
clear() {
|
|
174
|
+
pool.clear();
|
|
175
|
+
totalSize = 0;
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reset statistics counters.
|
|
180
|
+
*/
|
|
181
|
+
resetStats() {
|
|
182
|
+
hits = 0;
|
|
183
|
+
misses = 0;
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Current number of pooled elements.
|
|
188
|
+
* @type {number}
|
|
189
|
+
*/
|
|
190
|
+
get size() {
|
|
191
|
+
return totalSize;
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ============================================================================
|
|
197
|
+
// Default Singleton Pool
|
|
198
|
+
// ============================================================================
|
|
199
|
+
|
|
200
|
+
/** @type {ReturnType<typeof createElementPool>|null} */
|
|
201
|
+
let defaultPool = null;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get the default global element pool (lazy singleton).
|
|
205
|
+
*
|
|
206
|
+
* @returns {ReturnType<typeof createElementPool>} Default pool
|
|
207
|
+
*/
|
|
208
|
+
export function getPool() {
|
|
209
|
+
if (!defaultPool) {
|
|
210
|
+
defaultPool = createElementPool();
|
|
211
|
+
}
|
|
212
|
+
return defaultPool;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Reset and clear the default pool.
|
|
217
|
+
* Useful for testing or SSR cleanup.
|
|
218
|
+
*/
|
|
219
|
+
export function resetPool() {
|
|
220
|
+
if (defaultPool) {
|
|
221
|
+
defaultPool.clear();
|
|
222
|
+
}
|
|
223
|
+
defaultPool = null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export default {
|
|
227
|
+
createElementPool,
|
|
228
|
+
getPool,
|
|
229
|
+
resetPool
|
|
230
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pulse DOM Virtual Scrolling
|
|
3
|
+
*
|
|
4
|
+
* Renders only visible items plus an overscan buffer for large lists.
|
|
5
|
+
* Keeps DOM size constant regardless of data size.
|
|
6
|
+
*
|
|
7
|
+
* Related: ADR-0003, element recycling (ADR-0004), event delegation (ADR-0002)
|
|
8
|
+
*
|
|
9
|
+
* @module runtime/dom-virtual-list
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { pulse, effect, computed, batch } from './pulse.js';
|
|
13
|
+
import { getAdapter } from './dom-adapter.js';
|
|
14
|
+
import { list } from './dom-list.js';
|
|
15
|
+
import { delegate } from './dom-event-delegate.js';
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// Constants
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
const DEFAULT_OPTIONS = {
|
|
22
|
+
overscan: 5,
|
|
23
|
+
containerHeight: 400,
|
|
24
|
+
recycle: false,
|
|
25
|
+
on: null
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Virtual List
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a virtual scrolling list that renders only visible items.
|
|
34
|
+
*
|
|
35
|
+
* @param {Function|Pulse} getItems - Reactive data source returning array
|
|
36
|
+
* @param {Function} template - (item, index) => Node
|
|
37
|
+
* @param {Function} keyFn - (item) => unique key
|
|
38
|
+
* @param {Object} options - Configuration
|
|
39
|
+
* @param {number} options.itemHeight - Fixed row height in pixels (required)
|
|
40
|
+
* @param {number} [options.overscan=5] - Extra items above/below viewport
|
|
41
|
+
* @param {number|string} [options.containerHeight=400] - Viewport height in px or 'auto'
|
|
42
|
+
* @param {boolean} [options.recycle=false] - Enable element recycling for removed items
|
|
43
|
+
* @param {Object} [options.on] - Delegated event handlers: { eventType: (event, item, index) => void }
|
|
44
|
+
* @returns {Element} Scroll container element with scrollToIndex(index) and _dispose() methods
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const vlist = virtualList(
|
|
48
|
+
* () => items.get(),
|
|
49
|
+
* (item) => el('li', item.name),
|
|
50
|
+
* (item) => item.id,
|
|
51
|
+
* { itemHeight: 40, overscan: 5, containerHeight: 400 }
|
|
52
|
+
* );
|
|
53
|
+
* mount('#app', vlist);
|
|
54
|
+
*/
|
|
55
|
+
export function virtualList(getItems, template, keyFn, options = {}) {
|
|
56
|
+
const config = { ...DEFAULT_OPTIONS, ...options };
|
|
57
|
+
const { itemHeight, overscan, containerHeight, recycle, on: eventHandlers } = config;
|
|
58
|
+
|
|
59
|
+
if (!itemHeight || itemHeight <= 0) {
|
|
60
|
+
throw new Error('[Pulse] virtualList requires a positive itemHeight');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const dom = getAdapter();
|
|
64
|
+
|
|
65
|
+
// ---- Reactive state ----
|
|
66
|
+
const scrollTop = pulse(0);
|
|
67
|
+
|
|
68
|
+
// ---- DOM structure ----
|
|
69
|
+
|
|
70
|
+
// Outer container: fixed height, scrollable
|
|
71
|
+
const container = dom.createElement('div');
|
|
72
|
+
container.scrollTop = 0; // Initialize for mock/SSR compatibility
|
|
73
|
+
dom.setAttribute(container, 'role', 'list');
|
|
74
|
+
dom.setAttribute(container, 'aria-label', 'Virtual scrolling list');
|
|
75
|
+
setStyles(dom, container, {
|
|
76
|
+
'overflow-y': 'auto',
|
|
77
|
+
'position': 'relative'
|
|
78
|
+
});
|
|
79
|
+
if (typeof containerHeight === 'number') {
|
|
80
|
+
dom.setStyle(container, 'height', `${containerHeight}px`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Inner spacer: full height of all items (creates scrollbar)
|
|
84
|
+
const spacer = dom.createElement('div');
|
|
85
|
+
dom.setStyle(spacer, 'position', 'relative');
|
|
86
|
+
dom.appendChild(container, spacer);
|
|
87
|
+
|
|
88
|
+
// Viewport: positioned absolutely, holds rendered items
|
|
89
|
+
const viewport = dom.createElement('div');
|
|
90
|
+
setStyles(dom, viewport, {
|
|
91
|
+
'position': 'absolute',
|
|
92
|
+
'left': '0',
|
|
93
|
+
'right': '0',
|
|
94
|
+
'top': '0'
|
|
95
|
+
});
|
|
96
|
+
dom.appendChild(spacer, viewport);
|
|
97
|
+
|
|
98
|
+
// ---- Scroll handling (rAF-throttled) ----
|
|
99
|
+
let rafId = null;
|
|
100
|
+
|
|
101
|
+
const onScroll = () => {
|
|
102
|
+
if (rafId !== null) return;
|
|
103
|
+
rafId = (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : setTimeout)(() => {
|
|
104
|
+
rafId = null;
|
|
105
|
+
const top = container.scrollTop !== undefined ? container.scrollTop : 0;
|
|
106
|
+
scrollTop.set(top);
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
dom.addEventListener(container, 'scroll', onScroll, { passive: true });
|
|
111
|
+
|
|
112
|
+
// ---- Compute visible items slice ----
|
|
113
|
+
const visibleSlice = pulse([]);
|
|
114
|
+
const currentStartIndex = pulse(0);
|
|
115
|
+
let allItems = [];
|
|
116
|
+
|
|
117
|
+
effect(() => {
|
|
118
|
+
const items = typeof getItems === 'function' ? getItems() : getItems.get();
|
|
119
|
+
allItems = Array.isArray(items) ? items : [];
|
|
120
|
+
const totalItems = allItems.length;
|
|
121
|
+
const top = scrollTop.get();
|
|
122
|
+
|
|
123
|
+
// Update spacer height
|
|
124
|
+
dom.setStyle(spacer, 'height', `${totalItems * itemHeight}px`);
|
|
125
|
+
|
|
126
|
+
// Calculate visible range
|
|
127
|
+
const height = typeof containerHeight === 'number'
|
|
128
|
+
? containerHeight
|
|
129
|
+
: (container.clientHeight || 400);
|
|
130
|
+
|
|
131
|
+
let startIdx = Math.floor(top / itemHeight) - overscan;
|
|
132
|
+
let endIdx = Math.ceil((top + height) / itemHeight) + overscan;
|
|
133
|
+
startIdx = Math.max(0, startIdx);
|
|
134
|
+
endIdx = Math.min(totalItems, endIdx);
|
|
135
|
+
|
|
136
|
+
// Position viewport at start offset
|
|
137
|
+
dom.setStyle(viewport, 'top', `${startIdx * itemHeight}px`);
|
|
138
|
+
|
|
139
|
+
// ARIA: announce total count
|
|
140
|
+
dom.setAttribute(container, 'aria-rowcount', String(totalItems));
|
|
141
|
+
|
|
142
|
+
batch(() => {
|
|
143
|
+
currentStartIndex.set(startIdx);
|
|
144
|
+
visibleSlice.set(allItems.slice(startIdx, endIdx));
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// ---- Render visible items using list() ----
|
|
149
|
+
const listOptions = {};
|
|
150
|
+
if (recycle) {
|
|
151
|
+
listOptions.recycle = true;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Item map for event delegation
|
|
155
|
+
const itemMap = new Map();
|
|
156
|
+
const KEY_ATTR = 'data-pulse-key';
|
|
157
|
+
|
|
158
|
+
const rendered = list(
|
|
159
|
+
() => visibleSlice.get(),
|
|
160
|
+
(item, relativeIndex) => {
|
|
161
|
+
const absIndex = currentStartIndex.peek() + relativeIndex;
|
|
162
|
+
const node = template(item, absIndex);
|
|
163
|
+
const root = Array.isArray(node) ? node[0] : node;
|
|
164
|
+
if (root && dom.isElement(root)) {
|
|
165
|
+
dom.setAttribute(root, 'role', 'listitem');
|
|
166
|
+
// ARIA: set 1-based row index for screen readers
|
|
167
|
+
dom.setAttribute(root, 'aria-rowindex', String(absIndex + 1));
|
|
168
|
+
// Mark for event delegation
|
|
169
|
+
if (eventHandlers) {
|
|
170
|
+
const rawKey = keyFn(item, absIndex);
|
|
171
|
+
// Security: ensure key is a primitive to prevent collisions
|
|
172
|
+
const key = (typeof rawKey === 'string' || typeof rawKey === 'number')
|
|
173
|
+
? String(rawKey)
|
|
174
|
+
: String(absIndex);
|
|
175
|
+
dom.setAttribute(root, KEY_ATTR, key);
|
|
176
|
+
itemMap.set(key, { item, index: absIndex });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return node;
|
|
180
|
+
},
|
|
181
|
+
keyFn,
|
|
182
|
+
listOptions
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
dom.appendChild(viewport, rendered);
|
|
186
|
+
|
|
187
|
+
// ---- Event delegation on viewport ----
|
|
188
|
+
const delegateCleanups = [];
|
|
189
|
+
|
|
190
|
+
if (eventHandlers && typeof eventHandlers === 'object') {
|
|
191
|
+
// Set up delegation after microtask (viewport needs to be in DOM)
|
|
192
|
+
const setupDelegation = () => {
|
|
193
|
+
for (const [eventType, handler] of Object.entries(eventHandlers)) {
|
|
194
|
+
const cleanup = delegate(viewport, eventType, `[${KEY_ATTR}]`, (event, matchedEl) => {
|
|
195
|
+
const key = dom.getAttribute(matchedEl, KEY_ATTR);
|
|
196
|
+
const entry = itemMap.get(key);
|
|
197
|
+
if (entry) {
|
|
198
|
+
handler(event, entry.item, entry.index);
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
delegateCleanups.push(cleanup);
|
|
202
|
+
}
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
if (typeof queueMicrotask === 'function') {
|
|
206
|
+
queueMicrotask(setupDelegation);
|
|
207
|
+
} else {
|
|
208
|
+
setupDelegation();
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ---- Programmatic scroll API ----
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Scroll to bring a specific item index into view.
|
|
216
|
+
* @param {number} index - Zero-based item index
|
|
217
|
+
* @param {Object} [scrollOptions] - Options
|
|
218
|
+
* @param {'start'|'center'|'end'} [scrollOptions.align='start'] - Alignment in viewport
|
|
219
|
+
*/
|
|
220
|
+
container.scrollToIndex = (index, scrollOptions = {}) => {
|
|
221
|
+
const { align = 'start' } = scrollOptions;
|
|
222
|
+
const totalItems = allItems.length;
|
|
223
|
+
const clampedIndex = Math.max(0, Math.min(index, totalItems - 1));
|
|
224
|
+
|
|
225
|
+
const height = typeof containerHeight === 'number'
|
|
226
|
+
? containerHeight
|
|
227
|
+
: (container.clientHeight || 400);
|
|
228
|
+
|
|
229
|
+
let targetTop;
|
|
230
|
+
if (align === 'center') {
|
|
231
|
+
targetTop = clampedIndex * itemHeight - (height / 2) + (itemHeight / 2);
|
|
232
|
+
} else if (align === 'end') {
|
|
233
|
+
targetTop = (clampedIndex + 1) * itemHeight - height;
|
|
234
|
+
} else {
|
|
235
|
+
targetTop = clampedIndex * itemHeight;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
targetTop = Math.max(0, targetTop);
|
|
239
|
+
|
|
240
|
+
if (container.scrollTop !== undefined) {
|
|
241
|
+
container.scrollTop = targetTop;
|
|
242
|
+
}
|
|
243
|
+
scrollTop.set(targetTop);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
// ---- Cleanup method ----
|
|
247
|
+
container._dispose = () => {
|
|
248
|
+
dom.removeEventListener(container, 'scroll', onScroll);
|
|
249
|
+
if (rafId !== null) {
|
|
250
|
+
(typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : clearTimeout)(rafId);
|
|
251
|
+
rafId = null;
|
|
252
|
+
}
|
|
253
|
+
for (const cleanup of delegateCleanups) cleanup();
|
|
254
|
+
delegateCleanups.length = 0;
|
|
255
|
+
itemMap.clear();
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
return container;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Set multiple styles on an element.
|
|
263
|
+
* @param {DOMAdapter} dom
|
|
264
|
+
* @param {Element} element
|
|
265
|
+
* @param {Object} styles
|
|
266
|
+
* @private
|
|
267
|
+
*/
|
|
268
|
+
function setStyles(dom, element, styles) {
|
|
269
|
+
for (const [prop, value] of Object.entries(styles)) {
|
|
270
|
+
dom.setStyle(element, prop, value);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export default {
|
|
275
|
+
virtualList
|
|
276
|
+
};
|
package/runtime/dom.js
CHANGED
|
@@ -64,6 +64,15 @@ import {
|
|
|
64
64
|
// Advanced features
|
|
65
65
|
import { portal, errorBoundary, transition, whenTransition } from './dom-advanced.js';
|
|
66
66
|
|
|
67
|
+
// Element recycling pool (#60)
|
|
68
|
+
import { createElementPool, getPool, resetPool } from './dom-recycle.js';
|
|
69
|
+
|
|
70
|
+
// Event delegation (#56)
|
|
71
|
+
import { delegate, delegatedList } from './dom-event-delegate.js';
|
|
72
|
+
|
|
73
|
+
// Virtual scrolling (#59)
|
|
74
|
+
import { virtualList } from './dom-virtual-list.js';
|
|
75
|
+
|
|
67
76
|
// =============================================================================
|
|
68
77
|
// INITIALIZE CONTEXT UTILITIES FOR COMPONENT FACTORY
|
|
69
78
|
// =============================================================================
|
|
@@ -131,7 +140,19 @@ export {
|
|
|
131
140
|
portal,
|
|
132
141
|
errorBoundary,
|
|
133
142
|
transition,
|
|
134
|
-
whenTransition
|
|
143
|
+
whenTransition,
|
|
144
|
+
|
|
145
|
+
// Element recycling (#60)
|
|
146
|
+
createElementPool,
|
|
147
|
+
getPool,
|
|
148
|
+
resetPool,
|
|
149
|
+
|
|
150
|
+
// Event delegation (#56)
|
|
151
|
+
delegate,
|
|
152
|
+
delegatedList,
|
|
153
|
+
|
|
154
|
+
// Virtual scrolling (#59)
|
|
155
|
+
virtualList
|
|
135
156
|
};
|
|
136
157
|
|
|
137
158
|
// =============================================================================
|
|
@@ -184,5 +205,17 @@ export default {
|
|
|
184
205
|
|
|
185
206
|
// Diagnostics
|
|
186
207
|
getCacheMetrics,
|
|
187
|
-
resetCacheMetrics
|
|
208
|
+
resetCacheMetrics,
|
|
209
|
+
|
|
210
|
+
// Element recycling (#60)
|
|
211
|
+
createElementPool,
|
|
212
|
+
getPool,
|
|
213
|
+
resetPool,
|
|
214
|
+
|
|
215
|
+
// Event delegation (#56)
|
|
216
|
+
delegate,
|
|
217
|
+
delegatedList,
|
|
218
|
+
|
|
219
|
+
// Virtual scrolling (#59)
|
|
220
|
+
virtualList
|
|
188
221
|
};
|
package/runtime/pulse.js
CHANGED
|
@@ -142,6 +142,8 @@ export class ReactiveContext {
|
|
|
142
142
|
this.batchDepth = 0;
|
|
143
143
|
this.pendingEffects = new Set();
|
|
144
144
|
this.isRunningEffects = false;
|
|
145
|
+
// Generation counter for dependency tracking optimization (#58)
|
|
146
|
+
this.generation = 0;
|
|
145
147
|
// HMR support
|
|
146
148
|
this.currentModuleId = null;
|
|
147
149
|
this.effectRegistry = new Map();
|
|
@@ -155,6 +157,7 @@ export class ReactiveContext {
|
|
|
155
157
|
this.batchDepth = 0;
|
|
156
158
|
this.pendingEffects.clear();
|
|
157
159
|
this.isRunningEffects = false;
|
|
160
|
+
this.generation = 0;
|
|
158
161
|
this.currentModuleId = null;
|
|
159
162
|
this.effectRegistry.clear();
|
|
160
163
|
}
|
|
@@ -472,9 +475,16 @@ export class Pulse {
|
|
|
472
475
|
* });
|
|
473
476
|
*/
|
|
474
477
|
get() {
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
+
const current = activeContext.currentEffect;
|
|
479
|
+
if (current) {
|
|
480
|
+
// Optimization (#58): Skip redundant Set.add() if already tracked in this cycle.
|
|
481
|
+
// When an effect re-runs, generation increments and dependencies are cleared,
|
|
482
|
+
// so _generation won't match and we'll re-track. Within the same cycle,
|
|
483
|
+
// dependencies.has() avoids duplicate additions for repeated reads.
|
|
484
|
+
if (current._generation !== activeContext.generation || !current.dependencies.has(this)) {
|
|
485
|
+
this.#subscribers.add(current);
|
|
486
|
+
current.dependencies.add(this);
|
|
487
|
+
}
|
|
478
488
|
}
|
|
479
489
|
return this.#value;
|
|
480
490
|
}
|
|
@@ -516,7 +526,12 @@ export class Pulse {
|
|
|
516
526
|
* unsub(); // Stop listening
|
|
517
527
|
*/
|
|
518
528
|
subscribe(fn) {
|
|
519
|
-
const
|
|
529
|
+
const self = this;
|
|
530
|
+
const subscriber = {
|
|
531
|
+
run() { fn(self.peek()); },
|
|
532
|
+
dependencies: new Set(),
|
|
533
|
+
_isSubscriber: true
|
|
534
|
+
};
|
|
520
535
|
this.#subscribers.add(subscriber);
|
|
521
536
|
return () => this.#subscribers.delete(subscriber);
|
|
522
537
|
}
|
|
@@ -948,6 +963,10 @@ export function effect(fn, options = {}) {
|
|
|
948
963
|
}
|
|
949
964
|
effectFn.dependencies.clear();
|
|
950
965
|
|
|
966
|
+
// Stamp generation for dependency tracking optimization (#58)
|
|
967
|
+
activeContext.generation++;
|
|
968
|
+
effectFn._generation = activeContext.generation;
|
|
969
|
+
|
|
951
970
|
// Set as current effect for dependency tracking
|
|
952
971
|
const prevEffect = activeContext.currentEffect;
|
|
953
972
|
activeContext.currentEffect = effectFn;
|
|
@@ -961,7 +980,8 @@ export function effect(fn, options = {}) {
|
|
|
961
980
|
}
|
|
962
981
|
},
|
|
963
982
|
dependencies: new Set(),
|
|
964
|
-
cleanups: []
|
|
983
|
+
cleanups: [],
|
|
984
|
+
_generation: 0
|
|
965
985
|
};
|
|
966
986
|
|
|
967
987
|
// HMR: Register effect with current module
|
package/types/dom.d.ts
CHANGED
|
@@ -349,3 +349,136 @@ export declare function configureA11y(options: {
|
|
|
349
349
|
* Compute the Longest Increasing Subsequence (used internally by list())
|
|
350
350
|
*/
|
|
351
351
|
export declare function computeLIS(arr: number[]): number[];
|
|
352
|
+
|
|
353
|
+
// =============================================================================
|
|
354
|
+
// Event Delegation
|
|
355
|
+
// =============================================================================
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Attach a delegated event listener to a parent element.
|
|
359
|
+
* Events from descendants matching the selector bubble up to the parent.
|
|
360
|
+
* @returns Cleanup function to remove the listener
|
|
361
|
+
*/
|
|
362
|
+
export declare function delegate(
|
|
363
|
+
parent: HTMLElement,
|
|
364
|
+
eventType: string,
|
|
365
|
+
selector: string,
|
|
366
|
+
handler: (event: Event, matchedElement: HTMLElement) => void
|
|
367
|
+
): () => void;
|
|
368
|
+
|
|
369
|
+
/** Delegated list event handlers */
|
|
370
|
+
export interface DelegatedListHandlers<T> {
|
|
371
|
+
[eventType: string]: (event: Event, item: T, index: number) => void;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Options for delegatedList */
|
|
375
|
+
export interface DelegatedListOptions<T> {
|
|
376
|
+
on?: DelegatedListHandlers<T>;
|
|
377
|
+
recycle?: boolean;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Create a reactive list with delegated event handlers.
|
|
382
|
+
* A single listener is placed on the parent container for each event type.
|
|
383
|
+
*/
|
|
384
|
+
export declare function delegatedList<T>(
|
|
385
|
+
getItems: Reactive<T[]>,
|
|
386
|
+
template: ListTemplate<T>,
|
|
387
|
+
keyFn: KeyFn<T>,
|
|
388
|
+
options?: DelegatedListOptions<T>
|
|
389
|
+
): DocumentFragment;
|
|
390
|
+
|
|
391
|
+
// =============================================================================
|
|
392
|
+
// Virtual Scrolling
|
|
393
|
+
// =============================================================================
|
|
394
|
+
|
|
395
|
+
/** Options for virtualList */
|
|
396
|
+
export interface VirtualListOptions<T> {
|
|
397
|
+
/** Fixed row height in pixels (required) */
|
|
398
|
+
itemHeight: number;
|
|
399
|
+
/** Extra items above/below viewport (default: 5) */
|
|
400
|
+
overscan?: number;
|
|
401
|
+
/** Viewport height in px or 'auto' (default: 400) */
|
|
402
|
+
containerHeight?: number | 'auto';
|
|
403
|
+
/** Enable element recycling (default: false) */
|
|
404
|
+
recycle?: boolean;
|
|
405
|
+
/** Delegated event handlers */
|
|
406
|
+
on?: DelegatedListHandlers<T>;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Scroll alignment options */
|
|
410
|
+
export interface ScrollToIndexOptions {
|
|
411
|
+
/** Alignment in viewport (default: 'start') */
|
|
412
|
+
align?: 'start' | 'center' | 'end';
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Virtual list container element with extra methods */
|
|
416
|
+
export interface VirtualListElement extends HTMLElement {
|
|
417
|
+
/** Scroll to bring a specific item index into view */
|
|
418
|
+
scrollToIndex(index: number, options?: ScrollToIndexOptions): void;
|
|
419
|
+
/** Dispose scroll listeners and cleanup */
|
|
420
|
+
_dispose(): void;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Create a virtual scrolling list that renders only visible items.
|
|
425
|
+
*/
|
|
426
|
+
export declare function virtualList<T>(
|
|
427
|
+
getItems: Reactive<T[]>,
|
|
428
|
+
template: ListTemplate<T>,
|
|
429
|
+
keyFn: KeyFn<T>,
|
|
430
|
+
options: VirtualListOptions<T>
|
|
431
|
+
): VirtualListElement;
|
|
432
|
+
|
|
433
|
+
// =============================================================================
|
|
434
|
+
// Element Recycling Pool
|
|
435
|
+
// =============================================================================
|
|
436
|
+
|
|
437
|
+
/** Options for createElementPool */
|
|
438
|
+
export interface ElementPoolOptions {
|
|
439
|
+
/** Max recycled elements per tag name (default: 50) */
|
|
440
|
+
maxPerTag?: number;
|
|
441
|
+
/** Max total recycled elements (default: 200) */
|
|
442
|
+
maxTotal?: number;
|
|
443
|
+
/** Reset attributes/styles on release (default: true) */
|
|
444
|
+
resetOnRecycle?: boolean;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** Pool statistics */
|
|
448
|
+
export interface PoolStats {
|
|
449
|
+
size: number;
|
|
450
|
+
hits: number;
|
|
451
|
+
misses: number;
|
|
452
|
+
hitRate: number;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Element recycling pool */
|
|
456
|
+
export interface ElementPool {
|
|
457
|
+
/** Acquire an element from the pool or create a new one */
|
|
458
|
+
acquire(tagName: string): HTMLElement;
|
|
459
|
+
/** Release an element back to the pool for reuse */
|
|
460
|
+
release(element: HTMLElement): boolean;
|
|
461
|
+
/** Get pool statistics */
|
|
462
|
+
stats(): PoolStats;
|
|
463
|
+
/** Clear all pooled elements */
|
|
464
|
+
clear(): void;
|
|
465
|
+
/** Reset statistics counters */
|
|
466
|
+
resetStats(): void;
|
|
467
|
+
/** Current number of pooled elements */
|
|
468
|
+
readonly size: number;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Create an element recycling pool
|
|
473
|
+
*/
|
|
474
|
+
export declare function createElementPool(options?: ElementPoolOptions): ElementPool;
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Get the default global element pool (lazy singleton)
|
|
478
|
+
*/
|
|
479
|
+
export declare function getPool(): ElementPool;
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Reset and clear the default pool
|
|
483
|
+
*/
|
|
484
|
+
export declare function resetPool(): void;
|
package/types/index.d.ts
CHANGED
|
@@ -68,7 +68,21 @@ export {
|
|
|
68
68
|
portal,
|
|
69
69
|
errorBoundary,
|
|
70
70
|
transition,
|
|
71
|
-
whenTransition
|
|
71
|
+
whenTransition,
|
|
72
|
+
delegate,
|
|
73
|
+
DelegatedListHandlers,
|
|
74
|
+
DelegatedListOptions,
|
|
75
|
+
delegatedList,
|
|
76
|
+
VirtualListOptions,
|
|
77
|
+
ScrollToIndexOptions,
|
|
78
|
+
VirtualListElement,
|
|
79
|
+
virtualList,
|
|
80
|
+
ElementPoolOptions,
|
|
81
|
+
PoolStats,
|
|
82
|
+
ElementPool,
|
|
83
|
+
createElementPool,
|
|
84
|
+
getPool,
|
|
85
|
+
resetPool
|
|
72
86
|
} from './dom';
|
|
73
87
|
|
|
74
88
|
// Router
|