pulse-js-framework 1.7.37 → 1.8.0

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.
@@ -80,6 +80,9 @@ function globMatch(base, pattern, extensions) {
80
80
  match(dir, partIndex + 1);
81
81
  try {
82
82
  for (const entry of readdirSync(dir)) {
83
+ // Skip hidden files and node_modules
84
+ if (entry.startsWith('.') || entry === 'node_modules') continue;
85
+
83
86
  const full = join(dir, entry);
84
87
  try {
85
88
  if (statSync(full).isDirectory()) {
@@ -153,6 +156,9 @@ function matchFilesInDirRecursive(dir, pattern, extensions, results) {
153
156
  function walk(currentDir) {
154
157
  try {
155
158
  for (const entry of readdirSync(currentDir)) {
159
+ // Skip hidden files and node_modules
160
+ if (entry.startsWith('.') || entry === 'node_modules') continue;
161
+
156
162
  const full = join(currentDir, entry);
157
163
  try {
158
164
  const stat = statSync(full);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pulse-js-framework",
3
- "version": "1.7.37",
3
+ "version": "1.8.0",
4
4
  "description": "A declarative DOM framework with CSS selector-based structure and reactive pulsations",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -179,8 +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",
187
+ "test:vite-plugin": "node --test test/vite-plugin.test.js",
188
+ "test:memory-cleanup": "node --test test/memory-cleanup.test.js",
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",
184
192
  "build:netlify": "node scripts/build-netlify.js",
185
193
  "version": "node scripts/sync-version.js",
186
194
  "docs": "node cli/index.js dev docs"
package/runtime/a11y.js CHANGED
@@ -523,44 +523,39 @@ export function createPreferences() {
523
523
  const forcedColors = pulse(forcedColorsMode());
524
524
  const contrast = pulse(prefersContrast());
525
525
 
526
- if (typeof window !== 'undefined') {
527
- // Listen for preference changes
528
- window.matchMedia('(prefers-reduced-motion: reduce)').addEventListener('change', (e) => {
529
- reducedMotion.set(e.matches);
530
- });
531
-
532
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
533
- colorScheme.set(e.matches ? 'dark' : 'light');
534
- });
535
-
536
- window.matchMedia('(prefers-contrast: more)').addEventListener('change', (e) => {
537
- highContrast.set(e.matches);
538
- });
526
+ const listeners = [];
539
527
 
540
- window.matchMedia('(prefers-reduced-transparency: reduce)').addEventListener('change', (e) => {
541
- reducedTransparency.set(e.matches);
542
- });
543
-
544
- window.matchMedia('(forced-colors: active)').addEventListener('change', (e) => {
545
- forcedColors.set(e.matches ? 'active' : 'none');
546
- });
547
-
548
- // More granular contrast detection
549
- window.matchMedia('(prefers-contrast: more)').addEventListener('change', () => {
550
- contrast.set(prefersContrast());
551
- });
552
- window.matchMedia('(prefers-contrast: less)').addEventListener('change', () => {
553
- contrast.set(prefersContrast());
554
- });
528
+ if (typeof window !== 'undefined') {
529
+ const track = (query, handler) => {
530
+ const mql = window.matchMedia(query);
531
+ mql.addEventListener('change', handler);
532
+ listeners.push({ mql, handler });
533
+ };
534
+
535
+ track('(prefers-reduced-motion: reduce)', (e) => reducedMotion.set(e.matches));
536
+ track('(prefers-color-scheme: dark)', (e) => colorScheme.set(e.matches ? 'dark' : 'light'));
537
+ track('(prefers-contrast: more)', (e) => highContrast.set(e.matches));
538
+ track('(prefers-reduced-transparency: reduce)', (e) => reducedTransparency.set(e.matches));
539
+ track('(forced-colors: active)', (e) => forcedColors.set(e.matches ? 'active' : 'none'));
540
+ track('(prefers-contrast: more)', () => contrast.set(prefersContrast()));
541
+ track('(prefers-contrast: less)', () => contrast.set(prefersContrast()));
555
542
  }
556
543
 
544
+ const cleanup = () => {
545
+ for (const { mql, handler } of listeners) {
546
+ mql.removeEventListener('change', handler);
547
+ }
548
+ listeners.length = 0;
549
+ };
550
+
557
551
  return {
558
552
  reducedMotion,
559
553
  colorScheme,
560
554
  highContrast,
561
555
  reducedTransparency,
562
556
  forcedColors,
563
- contrast
557
+ contrast,
558
+ cleanup
564
559
  };
565
560
  }
566
561
 
@@ -1570,27 +1565,43 @@ export function createAnnouncementQueue(options = {}) {
1570
1565
 
1571
1566
  const queue = [];
1572
1567
  let isProcessing = false;
1568
+ let currentTimerId = null;
1569
+ let aborted = false;
1573
1570
  const queueLength = pulse(0);
1574
1571
 
1575
1572
  const processQueue = async () => {
1576
- if (isProcessing || queue.length === 0) return;
1573
+ if (isProcessing || queue.length === 0 || aborted) return;
1577
1574
 
1578
1575
  isProcessing = true;
1579
1576
 
1580
- while (queue.length > 0) {
1577
+ while (queue.length > 0 && !aborted) {
1581
1578
  const { message, priority, clearAfter } = queue.shift();
1582
1579
  queueLength.set(queue.length);
1583
1580
 
1584
1581
  announce(message, { priority, clearAfter });
1585
1582
 
1586
1583
  // Wait for announcement to be read
1587
- await new Promise(resolve => setTimeout(resolve,
1588
- Math.max(minDelay, clearAfter || 1000)));
1584
+ await new Promise(resolve => {
1585
+ currentTimerId = setTimeout(resolve,
1586
+ Math.max(minDelay, clearAfter || 1000));
1587
+ });
1588
+ currentTimerId = null;
1589
1589
  }
1590
1590
 
1591
1591
  isProcessing = false;
1592
1592
  };
1593
1593
 
1594
+ const dispose = () => {
1595
+ aborted = true;
1596
+ if (currentTimerId !== null) {
1597
+ clearTimeout(currentTimerId);
1598
+ currentTimerId = null;
1599
+ }
1600
+ queue.length = 0;
1601
+ queueLength.set(0);
1602
+ isProcessing = false;
1603
+ };
1604
+
1594
1605
  return {
1595
1606
  queueLength,
1596
1607
  /**
@@ -1599,6 +1610,7 @@ export function createAnnouncementQueue(options = {}) {
1599
1610
  * @param {object} options - Announcement options (priority, clearAfter)
1600
1611
  */
1601
1612
  add: (message, opts = {}) => {
1613
+ if (aborted) return;
1602
1614
  queue.push({ message, ...opts });
1603
1615
  queueLength.set(queue.length);
1604
1616
  processQueue();
@@ -1614,7 +1626,11 @@ export function createAnnouncementQueue(options = {}) {
1614
1626
  * Check if queue is being processed
1615
1627
  * @returns {boolean}
1616
1628
  */
1617
- isProcessing: () => isProcessing
1629
+ isProcessing: () => isProcessing,
1630
+ /**
1631
+ * Dispose the queue, cancelling any pending timers
1632
+ */
1633
+ dispose
1618
1634
  };
1619
1635
  }
1620
1636
 
package/runtime/async.js CHANGED
@@ -462,6 +462,11 @@ export function useAsync(asyncFn, options = {}) {
462
462
  }
463
463
  }
464
464
 
465
+ const dispose = () => {
466
+ versionController.cleanup();
467
+ };
468
+ onCleanup(dispose);
469
+
465
470
  // Execute immediately if requested
466
471
  if (immediate) {
467
472
  execute();
@@ -474,7 +479,8 @@ export function useAsync(asyncFn, options = {}) {
474
479
  status,
475
480
  execute,
476
481
  reset,
477
- abort
482
+ abort,
483
+ dispose
478
484
  };
479
485
  }
480
486
 
@@ -727,6 +733,13 @@ export function useResource(key, fetcher, options = {}) {
727
733
  fetch();
728
734
  }
729
735
 
736
+ const dispose = () => {
737
+ if (intervalId) {
738
+ clearInterval(intervalId);
739
+ intervalId = null;
740
+ }
741
+ };
742
+
730
743
  return {
731
744
  data,
732
745
  error,
@@ -737,7 +750,8 @@ export function useResource(key, fetcher, options = {}) {
737
750
  fetch,
738
751
  refresh,
739
752
  mutate,
740
- invalidate
753
+ invalidate,
754
+ dispose
741
755
  };
742
756
  }
743
757
 
@@ -537,6 +537,10 @@ export class MockElement extends MockNode {
537
537
  hasAttribute(name) {
538
538
  return this._attributes.has(name);
539
539
  }
540
+
541
+ getAttributeNames() {
542
+ return Array.from(this._attributes.keys());
543
+ }
540
544
  }
541
545
 
542
546
  /**
@@ -0,0 +1,220 @@
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
+ // Wrap template to add data-pulse-key attribute
149
+ const wrappedTemplate = (item, index) => {
150
+ const key = keyFn(item, index);
151
+ const node = template(item, index);
152
+ const root = Array.isArray(node) ? node[0] : node;
153
+
154
+ if (root && dom.isElement(root)) {
155
+ dom.setAttribute(root, KEY_ATTR, String(key));
156
+ }
157
+
158
+ itemMap.set(String(key), { item, index });
159
+ return node;
160
+ };
161
+
162
+ // Create list with wrapped template
163
+ const fragment = list(getItems, wrappedTemplate, keyFn, listOptions);
164
+
165
+ // Set up delegation on the fragment's parent when it's mounted
166
+ const cleanups = [];
167
+ let delegationSetUp = false;
168
+
169
+ /**
170
+ * Set up delegation on a parent element.
171
+ * Called lazily when the fragment is attached to the DOM.
172
+ * @param {Element} parent - Parent element
173
+ * @private
174
+ */
175
+ function setupDelegation(parent) {
176
+ if (delegationSetUp) return;
177
+ delegationSetUp = true;
178
+
179
+ for (const [eventType, handler] of Object.entries(handlers)) {
180
+ const cleanup = delegate(parent, eventType, `[${KEY_ATTR}]`, (event, matchedEl) => {
181
+ const key = dom.getAttribute(matchedEl, KEY_ATTR);
182
+ const entry = itemMap.get(key);
183
+ if (entry) {
184
+ handler(event, entry.item, entry.index);
185
+ }
186
+ });
187
+ cleanups.push(cleanup);
188
+ }
189
+ }
190
+
191
+ // Observe when fragment gets a parent by using a MutationObserver-like approach.
192
+ // Since fragments move to a parent on insertion, we check after microtask.
193
+ if (typeof queueMicrotask === 'function') {
194
+ queueMicrotask(() => {
195
+ // Find the parent by looking at the fragment's first child's parent
196
+ const firstChild = dom.getFirstChild(fragment);
197
+ if (firstChild) {
198
+ const parent = dom.getParentNode(firstChild);
199
+ if (parent) {
200
+ setupDelegation(parent);
201
+ }
202
+ }
203
+ });
204
+ }
205
+
206
+ // Attach setup helper for manual use
207
+ fragment._setupDelegation = setupDelegation;
208
+ fragment._cleanupDelegation = () => {
209
+ for (const cleanup of cleanups) cleanup();
210
+ cleanups.length = 0;
211
+ delegationSetUp = false;
212
+ };
213
+
214
+ return fragment;
215
+ }
216
+
217
+ export default {
218
+ delegate,
219
+ delegatedList
220
+ };
@@ -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');
@@ -150,6 +154,10 @@ export function list(getItems, template, keyFn = (item, i) => i) {
150
154
  for (const [key, entry] of itemNodes) {
151
155
  if (!newItemNodes.has(key)) {
152
156
  for (const node of entry.nodes) {
157
+ // Release to recycling pool before removing (if enabled)
158
+ if (pool && dom.isElement(node)) {
159
+ pool.release(node);
160
+ }
153
161
  dom.removeNode(node);
154
162
  }
155
163
  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,190 @@
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
+
16
+ // ============================================================================
17
+ // Constants
18
+ // ============================================================================
19
+
20
+ const DEFAULT_OPTIONS = {
21
+ overscan: 5,
22
+ containerHeight: 400,
23
+ recycle: false
24
+ };
25
+
26
+ // ============================================================================
27
+ // Virtual List
28
+ // ============================================================================
29
+
30
+ /**
31
+ * Create a virtual scrolling list that renders only visible items.
32
+ *
33
+ * @param {Function|Pulse} getItems - Reactive data source returning array
34
+ * @param {Function} template - (item, index) => Node
35
+ * @param {Function} keyFn - (item) => unique key
36
+ * @param {Object} options - Configuration
37
+ * @param {number} options.itemHeight - Fixed row height in pixels (required)
38
+ * @param {number} [options.overscan=5] - Extra items above/below viewport
39
+ * @param {number|string} [options.containerHeight=400] - Viewport height in px or 'auto'
40
+ * @param {boolean} [options.recycle=false] - Enable element recycling for removed items
41
+ * @returns {Element} Scroll container element
42
+ *
43
+ * @example
44
+ * const vlist = virtualList(
45
+ * () => items.get(),
46
+ * (item) => el('li', item.name),
47
+ * (item) => item.id,
48
+ * { itemHeight: 40, overscan: 5, containerHeight: 400 }
49
+ * );
50
+ * mount('#app', vlist);
51
+ */
52
+ export function virtualList(getItems, template, keyFn, options = {}) {
53
+ const config = { ...DEFAULT_OPTIONS, ...options };
54
+ const { itemHeight, overscan, containerHeight, recycle } = config;
55
+
56
+ if (!itemHeight || itemHeight <= 0) {
57
+ throw new Error('[Pulse] virtualList requires a positive itemHeight');
58
+ }
59
+
60
+ const dom = getAdapter();
61
+
62
+ // ---- Reactive state ----
63
+ const scrollTop = pulse(0);
64
+
65
+ // ---- DOM structure ----
66
+
67
+ // Outer container: fixed height, scrollable
68
+ const container = dom.createElement('div');
69
+ dom.setAttribute(container, 'role', 'list');
70
+ dom.setAttribute(container, 'aria-label', 'Virtual scrolling list');
71
+ setStyles(dom, container, {
72
+ 'overflow-y': 'auto',
73
+ 'position': 'relative'
74
+ });
75
+ if (typeof containerHeight === 'number') {
76
+ dom.setStyle(container, 'height', `${containerHeight}px`);
77
+ }
78
+
79
+ // Inner spacer: full height of all items (creates scrollbar)
80
+ const spacer = dom.createElement('div');
81
+ dom.setStyle(spacer, 'position', 'relative');
82
+ dom.appendChild(container, spacer);
83
+
84
+ // Viewport: positioned absolutely, holds rendered items
85
+ const viewport = dom.createElement('div');
86
+ setStyles(dom, viewport, {
87
+ 'position': 'absolute',
88
+ 'left': '0',
89
+ 'right': '0',
90
+ 'top': '0'
91
+ });
92
+ dom.appendChild(spacer, viewport);
93
+
94
+ // ---- Scroll handling (rAF-throttled) ----
95
+ let rafId = null;
96
+
97
+ const onScroll = () => {
98
+ if (rafId !== null) return;
99
+ rafId = (typeof requestAnimationFrame === 'function' ? requestAnimationFrame : setTimeout)(() => {
100
+ rafId = null;
101
+ const top = container.scrollTop !== undefined ? container.scrollTop : 0;
102
+ scrollTop.set(top);
103
+ });
104
+ };
105
+
106
+ dom.addEventListener(container, 'scroll', onScroll, { passive: true });
107
+
108
+ // ---- Compute visible items slice ----
109
+ const visibleSlice = pulse([]);
110
+
111
+ effect(() => {
112
+ const items = typeof getItems === 'function' ? getItems() : getItems.get();
113
+ const totalItems = Array.isArray(items) ? items.length : 0;
114
+ const top = scrollTop.get();
115
+
116
+ // Update spacer height
117
+ dom.setStyle(spacer, 'height', `${totalItems * itemHeight}px`);
118
+
119
+ // Calculate visible range
120
+ const height = typeof containerHeight === 'number'
121
+ ? containerHeight
122
+ : (container.clientHeight || 400);
123
+
124
+ let startIndex = Math.floor(top / itemHeight) - overscan;
125
+ let endIndex = Math.ceil((top + height) / itemHeight) + overscan;
126
+ startIndex = Math.max(0, startIndex);
127
+ endIndex = Math.min(totalItems, endIndex);
128
+
129
+ // Position viewport at start offset
130
+ dom.setStyle(viewport, 'top', `${startIndex * itemHeight}px`);
131
+
132
+ // ARIA: announce total count
133
+ dom.setAttribute(container, 'aria-rowcount', String(totalItems));
134
+
135
+ // Extract visible slice
136
+ const slice = Array.isArray(items) ? items.slice(startIndex, endIndex) : [];
137
+ visibleSlice.set(slice);
138
+ });
139
+
140
+ // ---- Render visible items using list() ----
141
+ const listOptions = {};
142
+ if (recycle) {
143
+ listOptions.recycle = true;
144
+ }
145
+
146
+ const rendered = list(
147
+ () => visibleSlice.get(),
148
+ (item, relativeIndex) => {
149
+ const node = template(item, relativeIndex);
150
+ // Set ARIA rowindex for accessibility
151
+ const root = Array.isArray(node) ? node[0] : node;
152
+ if (root && dom.isElement(root)) {
153
+ dom.setAttribute(root, 'role', 'listitem');
154
+ }
155
+ return node;
156
+ },
157
+ keyFn,
158
+ listOptions
159
+ );
160
+
161
+ dom.appendChild(viewport, rendered);
162
+
163
+ // ---- Cleanup method ----
164
+ container._dispose = () => {
165
+ dom.removeEventListener(container, 'scroll', onScroll);
166
+ if (rafId !== null) {
167
+ (typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : clearTimeout)(rafId);
168
+ rafId = null;
169
+ }
170
+ };
171
+
172
+ return container;
173
+ }
174
+
175
+ /**
176
+ * Set multiple styles on an element.
177
+ * @param {DOMAdapter} dom
178
+ * @param {Element} element
179
+ * @param {Object} styles
180
+ * @private
181
+ */
182
+ function setStyles(dom, element, styles) {
183
+ for (const [prop, value] of Object.entries(styles)) {
184
+ dom.setStyle(element, prop, value);
185
+ }
186
+ }
187
+
188
+ export default {
189
+ virtualList
190
+ };
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/form.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * and touched state tracking.
7
7
  */
8
8
 
9
- import { pulse, effect, computed, batch } from './pulse.js';
9
+ import { pulse, effect, computed, batch, onCleanup } from './pulse.js';
10
10
 
11
11
  /**
12
12
  * @typedef {Object} FieldState
@@ -698,6 +698,19 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
698
698
  });
699
699
  }
700
700
 
701
+ const dispose = () => {
702
+ for (const name of fieldNames) {
703
+ const timers = debounceTimers[name];
704
+ if (timers) {
705
+ for (const timerId of timers.values()) {
706
+ clearTimeout(timerId);
707
+ }
708
+ timers.clear();
709
+ }
710
+ }
711
+ };
712
+ onCleanup(dispose);
713
+
701
714
  return {
702
715
  fields,
703
716
  isValid,
@@ -715,7 +728,8 @@ export function useForm(initialValues, validationSchema = {}, options = {}) {
715
728
  reset,
716
729
  handleSubmit,
717
730
  setErrors,
718
- clearErrors
731
+ clearErrors,
732
+ dispose
719
733
  };
720
734
  }
721
735
 
@@ -905,6 +919,14 @@ export function useField(initialValue, rules = [], options = {}) {
905
919
  });
906
920
  };
907
921
 
922
+ const dispose = () => {
923
+ for (const timerId of debounceTimers.values()) {
924
+ clearTimeout(timerId);
925
+ }
926
+ debounceTimers.clear();
927
+ };
928
+ onCleanup(dispose);
929
+
908
930
  return {
909
931
  value,
910
932
  error,
@@ -918,7 +940,8 @@ export function useField(initialValue, rules = [], options = {}) {
918
940
  onBlur,
919
941
  reset,
920
942
  setError: (msg) => error.set(msg),
921
- clearError: () => error.set(null)
943
+ clearError: () => error.set(null),
944
+ dispose
922
945
  };
923
946
  }
924
947
 
@@ -1028,6 +1051,13 @@ export function useFieldArray(initialValues = [], itemRules = []) {
1028
1051
  return asyncResults.every(r => r === true);
1029
1052
  };
1030
1053
 
1054
+ const dispose = () => {
1055
+ for (const field of fieldsArray.get()) {
1056
+ field.dispose?.();
1057
+ }
1058
+ };
1059
+ onCleanup(dispose);
1060
+
1031
1061
  return {
1032
1062
  fields: fieldsArray,
1033
1063
  values,
@@ -1042,7 +1072,8 @@ export function useFieldArray(initialValues = [], itemRules = []) {
1042
1072
  replace,
1043
1073
  reset,
1044
1074
  validateAll,
1045
- validateAllSync
1075
+ validateAllSync,
1076
+ dispose
1046
1077
  };
1047
1078
  }
1048
1079
 
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
- if (activeContext.currentEffect) {
476
- this.#subscribers.add(activeContext.currentEffect);
477
- activeContext.currentEffect.dependencies.add(this);
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
  }
@@ -948,6 +958,10 @@ export function effect(fn, options = {}) {
948
958
  }
949
959
  effectFn.dependencies.clear();
950
960
 
961
+ // Stamp generation for dependency tracking optimization (#58)
962
+ activeContext.generation++;
963
+ effectFn._generation = activeContext.generation;
964
+
951
965
  // Set as current effect for dependency tracking
952
966
  const prevEffect = activeContext.currentEffect;
953
967
  activeContext.currentEffect = effectFn;
@@ -961,7 +975,8 @@ export function effect(fn, options = {}) {
961
975
  }
962
976
  },
963
977
  dependencies: new Set(),
964
- cleanups: []
978
+ cleanups: [],
979
+ _generation: 0
965
980
  };
966
981
 
967
982
  // HMR: Register effect with current module
package/runtime/router.js CHANGED
@@ -878,16 +878,18 @@ export function createRouter(options = {}) {
878
878
  const a = el('a', content);
879
879
  a.href = href;
880
880
 
881
- a.addEventListener('click', (e) => {
881
+ const handleClick = (e) => {
882
882
  // Allow ctrl/cmd+click for new tab
883
883
  if (e.ctrlKey || e.metaKey) return;
884
884
 
885
885
  e.preventDefault();
886
886
  navigate(path, options);
887
- });
887
+ };
888
+
889
+ a.addEventListener('click', handleClick);
888
890
 
889
891
  // Add active class when route matches
890
- effect(() => {
892
+ const disposeEffect = effect(() => {
891
893
  const current = currentPath.get();
892
894
  if (current === path || (options.exact === false && current.startsWith(path))) {
893
895
  a.classList.add(options.activeClass || 'active');
@@ -896,6 +898,11 @@ export function createRouter(options = {}) {
896
898
  }
897
899
  });
898
900
 
901
+ a.cleanup = () => {
902
+ a.removeEventListener('click', handleClick);
903
+ disposeEffect();
904
+ };
905
+
899
906
  return a;
900
907
  }
901
908