@humanspeak/svelte-virtual-list 0.3.1-beta.1 → 0.3.2

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.
Files changed (26) hide show
  1. package/dist/SvelteVirtualList.svelte +262 -191
  2. package/dist/SvelteVirtualList.svelte.d.ts +5 -5
  3. package/dist/index.d.ts +3 -1
  4. package/dist/index.js +2 -0
  5. package/dist/{reactive-height-manager → reactive-list-manager}/INTEGRATION_EXAMPLE.md +10 -10
  6. package/dist/{reactive-height-manager → reactive-list-manager}/README.md +17 -17
  7. package/dist/reactive-list-manager/ReactiveListManager.svelte.d.ts +221 -0
  8. package/dist/reactive-list-manager/ReactiveListManager.svelte.js +635 -0
  9. package/dist/reactive-list-manager/RecomputeScheduler.d.ts +12 -0
  10. package/dist/reactive-list-manager/RecomputeScheduler.js +54 -0
  11. package/dist/reactive-list-manager/benchmark.d.ts +5 -0
  12. package/dist/{reactive-height-manager → reactive-list-manager}/benchmark.js +3 -3
  13. package/dist/{reactive-height-manager → reactive-list-manager}/index.d.ts +8 -12
  14. package/dist/{reactive-height-manager → reactive-list-manager}/index.js +10 -13
  15. package/dist/{reactive-height-manager → reactive-list-manager}/test/TestComponent.svelte +9 -9
  16. package/dist/{reactive-height-manager → reactive-list-manager}/test/TestComponent.svelte.d.ts +5 -5
  17. package/dist/{reactive-height-manager → reactive-list-manager}/types.d.ts +9 -3
  18. package/dist/utils/virtualList.d.ts +2 -2
  19. package/dist/utils/virtualList.js +44 -17
  20. package/package.json +134 -133
  21. package/dist/reactive-height-manager/ReactiveHeightManager.svelte.d.ts +0 -116
  22. package/dist/reactive-height-manager/ReactiveHeightManager.svelte.js +0 -200
  23. package/dist/reactive-height-manager/benchmark.d.ts +0 -5
  24. package/dist/utils/resizeObserver.d.ts +0 -89
  25. package/dist/utils/resizeObserver.js +0 -119
  26. /package/dist/{reactive-height-manager → reactive-list-manager}/types.js +0 -0
@@ -0,0 +1,635 @@
1
+ import { RecomputeScheduler } from './RecomputeScheduler.js';
2
+ /**
3
+ * ReactiveListManager - A standalone reactive height calculation system
4
+ *
5
+ * Efficiently manages height calculations for virtualized lists by:
6
+ * - Tracking measured vs unmeasured items incrementally
7
+ * - Processing only dirty/changed items (O(dirty) instead of O(all))
8
+ * - Providing reactive state updates using Svelte 5 runes
9
+ * - Maintaining accurate total height calculations
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * const manager = new ReactiveListManager({ itemLength: 10000, estimatedHeight: 40 })
14
+ *
15
+ * // Process height changes incrementally
16
+ * manager.processDirtyHeights(dirtyResults)
17
+ *
18
+ * // Update calculated item height
19
+ * manager.calculatedItemHeight = 42
20
+ *
21
+ * // Get reactive total height (automatically updates)
22
+ * const totalHeight = manager.totalHeight
23
+ * ```
24
+ */
25
+ export class ReactiveListManager {
26
+ // Reactive state using Svelte 5 runes
27
+ _totalMeasuredHeight = $state(0);
28
+ _measuredCount = $state(0);
29
+ _itemLength = $state(0);
30
+ _itemHeight = $state(40);
31
+ _averageHeight = $state(40);
32
+ _totalHeight = $state(0);
33
+ _measuredFlags = null;
34
+ _initialized = $state(false);
35
+ _scrollTop = $state(0);
36
+ _containerElement = $state(null);
37
+ _viewportElement = $state(null);
38
+ _internalDebug = false;
39
+ _isReady = $state(false);
40
+ _dynamicUpdateInProgress = $state(false);
41
+ _dynamicUpdateDepth = $state(0);
42
+ // Grid detection (CSS-first)
43
+ _itemsWrapperElement = $state(null);
44
+ _gridDetected = $state(false);
45
+ _gridColumns = $state(1);
46
+ _gridObserver = null;
47
+ _mutationObserver = null;
48
+ // Internal cache of measured heights by index
49
+ _heightCache = {};
50
+ // Recompute scheduling
51
+ _scheduler = new RecomputeScheduler(() => this.recomputeDerivedHeights());
52
+ recomputeDerivedHeights() {
53
+ const average = this._measuredCount > 0
54
+ ? this._totalMeasuredHeight / this._measuredCount
55
+ : this._itemHeight;
56
+ this._averageHeight = average;
57
+ const unmeasuredCount = this._itemLength - this._measuredCount;
58
+ this._totalHeight = this._totalMeasuredHeight + unmeasuredCount * average;
59
+ }
60
+ recomputeIsReady() {
61
+ this._isReady = !!this._containerElement && !!this._viewportElement;
62
+ }
63
+ scheduleRecomputeDerivedHeights() {
64
+ // In jsdom/unit tests, recompute synchronously for determinism
65
+ const isJsdom = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
66
+ ? /jsdom/i.test(navigator.userAgent)
67
+ : false;
68
+ if (typeof window === 'undefined' || isJsdom) {
69
+ this.recomputeDerivedHeights();
70
+ return;
71
+ }
72
+ if (this._dynamicUpdateDepth > 0) {
73
+ this._scheduler.block();
74
+ return;
75
+ }
76
+ this._scheduler.schedule();
77
+ }
78
+ /**
79
+ * Get total measured height of all measured items
80
+ */
81
+ get totalMeasuredHeight() {
82
+ return this._totalMeasuredHeight;
83
+ }
84
+ /**
85
+ * Get count of items that have been measured
86
+ */
87
+ get measuredCount() {
88
+ return this._measuredCount;
89
+ }
90
+ /**
91
+ * Get total number of items in the list
92
+ */
93
+ get itemLength() {
94
+ return this._itemLength;
95
+ }
96
+ /**
97
+ * Get/Set the height to use for unmeasured items (reactive)
98
+ */
99
+ get itemHeight() {
100
+ return this._itemHeight;
101
+ }
102
+ set itemHeight(value) {
103
+ this._itemHeight = value;
104
+ this.scheduleRecomputeDerivedHeights();
105
+ }
106
+ /**
107
+ * Get/Set initialized flag
108
+ */
109
+ get initialized() {
110
+ return this._initialized;
111
+ }
112
+ set initialized(value) {
113
+ if (this._initialized) {
114
+ throw new Error('ReactiveListManager: initialized flag cannot be set to true after it has been set to true');
115
+ }
116
+ this._initialized = value;
117
+ }
118
+ /**
119
+ * Get/Set current scrollTop (reactive)
120
+ */
121
+ get scrollTop() {
122
+ return this._scrollTop;
123
+ }
124
+ set scrollTop(value) {
125
+ // Debug: warn if the same value is set excessively within a short window
126
+ if (this._internalDebug) {
127
+ this.#debugCheckScrollTopRepeat(value);
128
+ }
129
+ this._scrollTop = value;
130
+ }
131
+ /**
132
+ * Container element reference (reactive, nullable)
133
+ *
134
+ * Why both `containerElement` and `container` exist:
135
+ * - `containerElement` is a nullable, reactive reference intended for Svelte `bind:this` wiring
136
+ * from components. It may be temporarily null during mount/unmount and is safe to read as
137
+ * a possibly-null value. Setting it more than once is prohibited to catch wiring bugs early.
138
+ * - `container` is the non-null accessor for internal consumers that require a definite
139
+ * HTMLElement once the manager is wired. It throws until the manager is `isReady === true`
140
+ * (i.e., both container and viewport are present). Use this when you want a guaranteed DOM node.
141
+ */
142
+ get containerElement() {
143
+ return this._containerElement;
144
+ }
145
+ get container() {
146
+ if (!this._isReady) {
147
+ throw new Error('ReactiveListManager: container is not ready');
148
+ }
149
+ return this._containerElement;
150
+ }
151
+ set containerElement(el) {
152
+ this._containerElement = el;
153
+ this.recomputeIsReady();
154
+ }
155
+ /**
156
+ * Viewport element reference (reactive, nullable)
157
+ *
158
+ * Why both `viewportElement` and `viewport` exist:
159
+ * - `viewportElement` is a nullable, reactive reference intended for Svelte `bind:this` wiring
160
+ * from components. It may be temporarily null during mount/unmount and is safe to read as
161
+ * a possibly-null value. Setting it more than once is prohibited to catch wiring bugs early.
162
+ * - `viewport` is the non-null accessor for internal consumers that require a definite
163
+ * HTMLElement once the manager is wired. It throws until the manager is `isReady === true`
164
+ * (i.e., both container and viewport are present). Use this when you want a guaranteed DOM node.
165
+ */
166
+ get viewportElement() {
167
+ return this._viewportElement;
168
+ }
169
+ get viewport() {
170
+ if (!this._isReady) {
171
+ throw new Error('ReactiveListManager: viewport is not ready');
172
+ }
173
+ return this._viewportElement;
174
+ }
175
+ set viewportElement(el) {
176
+ this._viewportElement = el;
177
+ this.recomputeIsReady();
178
+ }
179
+ /**
180
+ * Items wrapper element reference (reactive, nullable)
181
+ *
182
+ * Used for CSS-based grid detection. When set, the manager will auto-detect
183
+ * whether the items container is a grid and how many columns it defines.
184
+ */
185
+ get itemsWrapperElement() {
186
+ return this._itemsWrapperElement;
187
+ }
188
+ set itemsWrapperElement(el) {
189
+ // Detach previous observer if element changed
190
+ if (this._itemsWrapperElement !== el) {
191
+ if (this._gridObserver) {
192
+ try {
193
+ this._gridObserver.disconnect();
194
+ }
195
+ catch {
196
+ // no-op
197
+ }
198
+ this._gridObserver = null;
199
+ }
200
+ if (this._mutationObserver) {
201
+ try {
202
+ this._mutationObserver.disconnect();
203
+ }
204
+ catch {
205
+ // no-op
206
+ }
207
+ this._mutationObserver = null;
208
+ }
209
+ }
210
+ this._itemsWrapperElement = el;
211
+ if (!el) {
212
+ this._gridDetected = false;
213
+ this._gridColumns = 1;
214
+ return;
215
+ }
216
+ // Attach new observer and detect immediately
217
+ this.#attachGridObserver();
218
+ this.#attachMutationObserver();
219
+ this.#detectGridColumns();
220
+ }
221
+ /** Whether a CSS grid was detected on the items wrapper */
222
+ get gridDetected() {
223
+ return this._gridDetected;
224
+ }
225
+ /** Number of columns when a grid is detected; 1 when not a grid */
226
+ get gridColumns() {
227
+ return this._gridColumns;
228
+ }
229
+ get isReady() {
230
+ return this._isReady;
231
+ }
232
+ /**
233
+ * Whether a dynamic update is currently running.
234
+ * Set to true while `runDynamicUpdate` is executing.
235
+ */
236
+ get isDynamicUpdateInProgress() {
237
+ return this._dynamicUpdateDepth > 0;
238
+ }
239
+ /**
240
+ * Begin a dynamic update. Handles nested calls: the first call disables UA scroll anchoring,
241
+ * subsequent calls just increment depth. Safe to call when not wired; styles are only toggled
242
+ * when both container and viewport are ready.
243
+ */
244
+ startDynamicUpdate() {
245
+ const isOuter = this._dynamicUpdateDepth === 0;
246
+ this._dynamicUpdateDepth += 1;
247
+ if (isOuter) {
248
+ this._dynamicUpdateInProgress = true;
249
+ if (this._isReady && this._viewportElement) {
250
+ this._viewportElement.style.setProperty('overflow-anchor', 'none');
251
+ }
252
+ }
253
+ }
254
+ /**
255
+ * End a dynamic update started by `startDynamicUpdate`. Handles nesting: only the final
256
+ * corresponding end call re-enables UA scroll anchoring. Guards against underflow.
257
+ */
258
+ endDynamicUpdate() {
259
+ if (this._dynamicUpdateDepth <= 0) {
260
+ return;
261
+ }
262
+ this._dynamicUpdateDepth -= 1;
263
+ if (this._dynamicUpdateDepth === 0) {
264
+ if (this._isReady && this._viewportElement) {
265
+ this._viewportElement.style.setProperty('overflow-anchor', 'auto');
266
+ }
267
+ this._dynamicUpdateInProgress = false;
268
+ this._scheduler.unblock();
269
+ }
270
+ }
271
+ /**
272
+ * Run a dynamic update with UA scroll anchoring disabled, then restore it.
273
+ * Accepts a sync or async function and ensures `overflow-anchor` is toggled
274
+ * around the operation. If the manager isn't ready yet, it simply executes `fn`.
275
+ */
276
+ async runDynamicUpdate(fn) {
277
+ this.startDynamicUpdate();
278
+ try {
279
+ const result = fn();
280
+ return (result instanceof Promise ? await result : result);
281
+ }
282
+ finally {
283
+ this.endDynamicUpdate();
284
+ }
285
+ }
286
+ // --- Internal debug helpers (non-exported) ---
287
+ #debugLastScrollValue = null;
288
+ #debugWindowStartMs = 0;
289
+ #debugRepeatCount = 0;
290
+ #debugWarnedThisWindow = false;
291
+ #debugCheckScrollTopRepeat(value) {
292
+ const now = typeof performance !== 'undefined' && performance.now ? performance.now() : Date.now();
293
+ if (this.#debugLastScrollValue === value) {
294
+ if (now - this.#debugWindowStartMs <= 1000) {
295
+ this.#debugRepeatCount += 1;
296
+ if (this.#debugRepeatCount > 10 && !this.#debugWarnedThisWindow) {
297
+ this.#debugWarnedThisWindow = true;
298
+ console.warn('\n================ SvelteVirtualList DEBUG ================\n' +
299
+ `scrollTop assigned same value ${value} > 10 times within 1s\n` +
300
+ `count=${this.#debugRepeatCount}, windowStart=${Math.round(this.#debugWindowStartMs)}ms\n` +
301
+ 'This may indicate redundant updates or feedback loops.\n' +
302
+ '========================================================\n');
303
+ }
304
+ }
305
+ else {
306
+ // New time window for the same value
307
+ this.#debugWindowStartMs = now;
308
+ this.#debugRepeatCount = 1;
309
+ this.#debugWarnedThisWindow = false;
310
+ }
311
+ }
312
+ else {
313
+ // Different value: reset tracking
314
+ this.#debugLastScrollValue = value;
315
+ this.#debugWindowStartMs = now;
316
+ this.#debugRepeatCount = 1;
317
+ this.#debugWarnedThisWindow = false;
318
+ }
319
+ }
320
+ /**
321
+ * Get the calculated average height of measured items
322
+ * Falls back to itemHeight if no items have been measured yet
323
+ */
324
+ get averageHeight() {
325
+ return this._averageHeight;
326
+ }
327
+ /**
328
+ * Get the reactive total height of all items (measured + estimated)
329
+ * This automatically updates when any dependencies change
330
+ */
331
+ get totalHeight() {
332
+ return this._totalHeight;
333
+ }
334
+ /**
335
+ * Test helper: force a recompute immediately (bypasses scheduler).
336
+ */
337
+ flushRecompute = () => {
338
+ this.recomputeDerivedHeights();
339
+ };
340
+ /**
341
+ * Read-only view of measured heights cache
342
+ */
343
+ getHeightCache() {
344
+ return this._heightCache;
345
+ }
346
+ /**
347
+ * Create a new ReactiveListManager instance
348
+ *
349
+ * @param config - Configuration object containing itemLength and itemHeight
350
+ */
351
+ constructor(config) {
352
+ this._itemLength = config.itemLength;
353
+ this._itemHeight = config.itemHeight;
354
+ this._internalDebug = config.internalDebug ?? false;
355
+ this._measuredFlags = new Uint8Array(Math.max(0, this._itemLength));
356
+ this.recomputeDerivedHeights();
357
+ }
358
+ /**
359
+ * Process height changes incrementally - O(dirty items) instead of O(all items)
360
+ *
361
+ * This is the core optimization: instead of recalculating totals for all items,
362
+ * we only process the items that have changed, maintaining running totals.
363
+ *
364
+ * Accepts any object that has index, oldHeight, and newHeight properties,
365
+ * allowing consumers to pass objects with additional fields.
366
+ *
367
+ * @param dirtyResults - Array of height changes to process
368
+ */
369
+ processDirtyHeights(dirtyResults) {
370
+ if (dirtyResults.length === 0)
371
+ return;
372
+ // Batch calculate changes to trigger reactivity only once
373
+ let heightDelta = 0;
374
+ let countDelta = 0;
375
+ for (const change of dirtyResults) {
376
+ const { index, oldHeight, newHeight } = change;
377
+ // Remove old contribution if it existed
378
+ if (oldHeight !== undefined) {
379
+ heightDelta -= oldHeight;
380
+ countDelta -= 1;
381
+ }
382
+ // Add new contribution
383
+ if (newHeight !== undefined) {
384
+ heightDelta += newHeight;
385
+ countDelta += 1;
386
+ this._heightCache[index] = newHeight;
387
+ }
388
+ else {
389
+ // Unset measurement
390
+ delete this._heightCache[index];
391
+ }
392
+ // Track measured flag (best-effort; full coalescing handled separately)
393
+ if (this._measuredFlags && index >= 0 && index < this._measuredFlags.length) {
394
+ this._measuredFlags[index] = 1;
395
+ }
396
+ }
397
+ // IDK... no one can explain it to me,.. but its here like this... it cannot be:
398
+ // if (heightDelta === 0 && countDelta === 0) return
399
+ const isJsdom = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
400
+ ? /jsdom/i.test(navigator.userAgent)
401
+ : false;
402
+ const isNonBrowser = typeof window === 'undefined' || isJsdom;
403
+ if (isNonBrowser) {
404
+ if (heightDelta === 0 && countDelta === 0)
405
+ return;
406
+ }
407
+ else {
408
+ if (countDelta === 0)
409
+ return;
410
+ }
411
+ // Apply all changes at once - triggers reactivity only once
412
+ this._totalMeasuredHeight += heightDelta;
413
+ this._measuredCount += countDelta;
414
+ this.scheduleRecomputeDerivedHeights();
415
+ }
416
+ /**
417
+ * Update when items array length changes
418
+ *
419
+ * @param newLength - New total number of items
420
+ */
421
+ updateItemLength(newLength) {
422
+ this._itemLength = newLength;
423
+ this._measuredFlags = new Uint8Array(Math.max(0, newLength));
424
+ // Immediate recompute so new items become visible without delay
425
+ this.recomputeDerivedHeights();
426
+ }
427
+ /**
428
+ * Update estimated height for unmeasured items
429
+ *
430
+ * @param newEstimatedHeight - New estimated height
431
+ */
432
+ updateEstimatedHeight(newEstimatedHeight) {
433
+ // Keep a single source of truth for the estimated height
434
+ this._itemHeight = newEstimatedHeight;
435
+ this.scheduleRecomputeDerivedHeights();
436
+ }
437
+ /**
438
+ * Set a single measured height and update totals
439
+ */
440
+ setMeasuredHeight(index, height) {
441
+ if (index < 0 || index >= this._itemLength)
442
+ return;
443
+ const prev = this._heightCache[index];
444
+ if (Number.isFinite(prev) && prev > 0) {
445
+ this._totalMeasuredHeight -= prev;
446
+ }
447
+ else {
448
+ this._measuredCount += 1;
449
+ }
450
+ if (Number.isFinite(height) && height > 0) {
451
+ this._heightCache[index] = height;
452
+ this._totalMeasuredHeight += height;
453
+ this.scheduleRecomputeDerivedHeights();
454
+ }
455
+ }
456
+ /**
457
+ * Reset all state to initial values
458
+ *
459
+ * Useful for testing or when completely reinitializing the list
460
+ */
461
+ reset() {
462
+ this._totalMeasuredHeight = 0;
463
+ this._measuredCount = 0;
464
+ this._measuredFlags = this._itemLength > 0 ? new Uint8Array(this._itemLength) : null;
465
+ // Note: Don't reset _itemLength, _itemHeight as they represent configuration, not measured state
466
+ this.scheduleRecomputeDerivedHeights();
467
+ }
468
+ /**
469
+ * Get comprehensive debug information
470
+ *
471
+ * @returns Debug information object
472
+ */
473
+ getDebugInfo() {
474
+ const info = {
475
+ totalMeasuredHeight: this._totalMeasuredHeight,
476
+ measuredCount: this._measuredCount,
477
+ itemLength: this._itemLength,
478
+ coveragePercent: this._itemLength > 0 ? (this._measuredCount / this._itemLength) * 100 : 0,
479
+ itemHeight: this._itemHeight,
480
+ averageHeight: this.averageHeight,
481
+ totalHeight: this.totalHeight,
482
+ gridDetected: this._gridDetected,
483
+ gridColumns: this._gridColumns
484
+ };
485
+ return info;
486
+ }
487
+ /**
488
+ * Get the percentage of items that have been measured
489
+ *
490
+ * @returns Percentage (0-100) of measured items
491
+ */
492
+ getMeasurementCoverage() {
493
+ return this.getDebugInfo().coveragePercent;
494
+ }
495
+ /**
496
+ * Check if the manager has sufficient measurement data
497
+ *
498
+ * @param threshold - Minimum percentage of items that should be measured (default: 10)
499
+ * @returns true if coverage meets threshold
500
+ */
501
+ hasSufficientMeasurements(threshold = 10) {
502
+ return this.getMeasurementCoverage() >= threshold;
503
+ }
504
+ /** Public: Re-run CSS grid detection immediately */
505
+ recomputeGridDetection() {
506
+ this.#detectGridColumns();
507
+ }
508
+ // --- Grid detection helpers ---
509
+ #attachGridObserver() {
510
+ const el = this._itemsWrapperElement;
511
+ if (typeof window === 'undefined' || !el)
512
+ return;
513
+ // Observe size changes to recompute column count responsively
514
+ try {
515
+ this._gridObserver = new ResizeObserver(() => {
516
+ this.#detectGridColumns();
517
+ });
518
+ this._gridObserver.observe(el);
519
+ }
520
+ catch {
521
+ // Ignore observer failures in non-browser environments
522
+ this._gridObserver = null;
523
+ }
524
+ }
525
+ #attachMutationObserver() {
526
+ const el = this._itemsWrapperElement;
527
+ if (typeof window === 'undefined' || !el)
528
+ return;
529
+ try {
530
+ this._mutationObserver = new MutationObserver((records) => {
531
+ for (const rec of records) {
532
+ if (rec.type === 'attributes' &&
533
+ (rec.attributeName === 'class' || rec.attributeName === 'style')) {
534
+ this.#detectGridColumns();
535
+ break;
536
+ }
537
+ }
538
+ });
539
+ this._mutationObserver.observe(el, {
540
+ attributes: true,
541
+ attributeFilter: ['class', 'style']
542
+ });
543
+ }
544
+ catch {
545
+ this._mutationObserver = null;
546
+ }
547
+ }
548
+ #detectGridColumns() {
549
+ const el = this._itemsWrapperElement;
550
+ if (!el) {
551
+ this._gridDetected = false;
552
+ this._gridColumns = 1;
553
+ return;
554
+ }
555
+ // getComputedStyle based detection
556
+ let detected = false;
557
+ let columns = 1;
558
+ try {
559
+ const style = getComputedStyle(el);
560
+ if (style.display === 'grid') {
561
+ const template = style.gridTemplateColumns;
562
+ const repeatMatch = /repeat\(\s*(\d+)\s*,/i.exec(template);
563
+ if (repeatMatch && repeatMatch[1]) {
564
+ columns = Math.max(1, parseInt(repeatMatch[1], 10));
565
+ detected = true;
566
+ }
567
+ else if (template && template !== 'none') {
568
+ const count = this.#countTracksFromTemplate(template);
569
+ if (Number.isFinite(count) && count > 0) {
570
+ columns = count;
571
+ detected = true;
572
+ }
573
+ }
574
+ }
575
+ }
576
+ catch {
577
+ // Ignore and fall back to geometry detection
578
+ }
579
+ // Fallback: infer from first row geometry if style approach failed
580
+ if (!detected) {
581
+ const children = el.children;
582
+ if (children && children.length > 0) {
583
+ const firstTop = children[0].getBoundingClientRect().top;
584
+ let countSameRow = 0;
585
+ for (let i = 0; i < children.length; i += 1) {
586
+ const top = children[i].getBoundingClientRect().top;
587
+ if (Math.abs(top - firstTop) <= 1) {
588
+ countSameRow += 1;
589
+ }
590
+ else {
591
+ break;
592
+ }
593
+ }
594
+ if (countSameRow > 0) {
595
+ columns = countSameRow;
596
+ detected = countSameRow > 1;
597
+ }
598
+ }
599
+ }
600
+ // Assign reactive state
601
+ this._gridDetected = detected;
602
+ this._gridColumns = Math.max(1, columns);
603
+ if (this._internalDebug) {
604
+ console.info('[ReactiveListManager] grid detection:', {
605
+ detected: this._gridDetected,
606
+ columns: this._gridColumns
607
+ });
608
+ }
609
+ }
610
+ #countTracksFromTemplate(template) {
611
+ // Count top-level tokens in grid-template-columns
612
+ let depth = 0;
613
+ let tokens = 0;
614
+ let inToken = false;
615
+ for (let i = 0; i < template.length; i += 1) {
616
+ const ch = template[i];
617
+ if (ch === '(')
618
+ depth += 1;
619
+ else if (ch === ')')
620
+ depth = Math.max(0, depth - 1);
621
+ if (depth === 0 && /\s/.test(ch)) {
622
+ if (inToken) {
623
+ tokens += 1;
624
+ inToken = false;
625
+ }
626
+ }
627
+ else if (ch !== ' ') {
628
+ inToken = true;
629
+ }
630
+ }
631
+ if (inToken)
632
+ tokens += 1;
633
+ return tokens;
634
+ }
635
+ }
@@ -0,0 +1,12 @@
1
+ export declare class RecomputeScheduler {
2
+ private onRecompute;
3
+ private isScheduled;
4
+ private isPending;
5
+ private blockDepth;
6
+ private timeoutId;
7
+ constructor(onRecompute: () => void);
8
+ schedule: () => void;
9
+ block: () => void;
10
+ unblock: () => void;
11
+ cancel: () => void;
12
+ }
@@ -0,0 +1,54 @@
1
+ export class RecomputeScheduler {
2
+ onRecompute;
3
+ isScheduled = false;
4
+ isPending = false;
5
+ blockDepth = 0;
6
+ timeoutId = null;
7
+ constructor(onRecompute) {
8
+ this.onRecompute = onRecompute;
9
+ }
10
+ schedule = () => {
11
+ if (this.blockDepth > 0) {
12
+ this.isPending = true;
13
+ return;
14
+ }
15
+ if (this.isScheduled)
16
+ return;
17
+ this.isScheduled = true;
18
+ if (this.timeoutId) {
19
+ clearTimeout(this.timeoutId);
20
+ this.timeoutId = null;
21
+ }
22
+ this.timeoutId = setTimeout(() => {
23
+ this.timeoutId = null;
24
+ this.isScheduled = false;
25
+ this.onRecompute();
26
+ }, 0);
27
+ };
28
+ block = () => {
29
+ this.blockDepth += 1;
30
+ if (this.timeoutId) {
31
+ clearTimeout(this.timeoutId);
32
+ this.timeoutId = null;
33
+ this.isScheduled = false;
34
+ this.isPending = true;
35
+ }
36
+ };
37
+ unblock = () => {
38
+ if (this.blockDepth === 0)
39
+ return;
40
+ this.blockDepth -= 1;
41
+ if (this.blockDepth === 0 && this.isPending) {
42
+ this.isPending = false;
43
+ this.onRecompute();
44
+ }
45
+ };
46
+ cancel = () => {
47
+ if (this.timeoutId) {
48
+ clearTimeout(this.timeoutId);
49
+ this.timeoutId = null;
50
+ }
51
+ this.isScheduled = false;
52
+ this.isPending = false;
53
+ };
54
+ }
@@ -0,0 +1,5 @@
1
+ export declare function benchmarkListManager(itemCount: number, dirtyCount: number, iterations?: number): {
2
+ avgTime: number;
3
+ totalTime: number;
4
+ opsPerSecond: number;
5
+ };