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.
- package/cli/utils/file-utils.js +6 -0
- package/package.json +9 -1
- package/runtime/a11y.js +50 -34
- package/runtime/async.js +16 -2
- package/runtime/dom-adapter.js +4 -0
- package/runtime/dom-event-delegate.js +220 -0
- package/runtime/dom-list.js +9 -1
- package/runtime/dom-recycle.js +230 -0
- package/runtime/dom-virtual-list.js +190 -0
- package/runtime/dom.js +35 -2
- package/runtime/form.js +35 -4
- package/runtime/pulse.js +19 -4
- package/runtime/router.js +10 -3
package/cli/utils/file-utils.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
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 =>
|
|
1588
|
-
|
|
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
|
|
package/runtime/dom-adapter.js
CHANGED
|
@@ -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
|
+
};
|
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');
|
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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
|
|