@jinntec/fore 3.3.1 → 4.0.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.
Files changed (73) hide show
  1. package/README.md +98 -70
  2. package/dist/fore-dev.js +5593 -6832
  3. package/dist/fore.js +5602 -4887
  4. package/index.js +5 -10
  5. package/package.json +7 -7
  6. package/resources/fore.css +33 -0
  7. package/src/DependencyNotifyingDomFacade.js +90 -21
  8. package/src/DependentXPathQueries.js +15 -2
  9. package/src/ForeElementMixin.js +110 -16
  10. package/src/UndoManager.js +267 -0
  11. package/src/actions/abstract-action.js +71 -30
  12. package/src/actions/fx-action.js +5 -0
  13. package/src/actions/fx-append.js +3 -3
  14. package/src/actions/fx-commit-history.js +26 -0
  15. package/src/actions/fx-hide.js +1 -1
  16. package/src/actions/fx-insert.js +25 -22
  17. package/src/actions/fx-load.js +5 -5
  18. package/src/actions/fx-redo.js +58 -0
  19. package/src/actions/fx-refresh.js +2 -2
  20. package/src/actions/fx-reload.js +1 -1
  21. package/src/actions/fx-replace.js +1 -1
  22. package/src/actions/fx-send.js +27 -5
  23. package/src/actions/fx-setattribute.js +11 -7
  24. package/src/actions/fx-undo.js +58 -0
  25. package/src/createNodes.js +314 -0
  26. package/src/fore.js +53 -18
  27. package/src/functions/fx-functionlib.js +10 -10
  28. package/src/fx-bind.js +30 -18
  29. package/src/fx-fore.js +222 -200
  30. package/src/fx-instance.js +18 -1
  31. package/src/fx-model.js +236 -69
  32. package/src/fx-submission.js +37 -29
  33. package/src/fx-var.js +49 -13
  34. package/src/getInScopeContext.js +1 -1
  35. package/src/json/JSONDomFacade.js +1 -1
  36. package/src/json/JSONLens.js +2 -2
  37. package/src/ui/UIElement.js +18 -8
  38. package/src/ui/abstract-control.js +45 -3
  39. package/src/ui/fx-alert.js +4 -0
  40. package/src/ui/fx-case.js +1 -1
  41. package/src/ui/fx-container.js +3 -0
  42. package/src/ui/fx-control-menu.js +79 -11
  43. package/src/ui/fx-control.js +130 -41
  44. package/src/ui/fx-dialog.js +5 -0
  45. package/src/ui/fx-items.js +6 -6
  46. package/src/ui/fx-output.js +37 -1
  47. package/src/ui/fx-repeat.js +1065 -103
  48. package/src/ui/fx-repeatitem.js +4 -1
  49. package/src/ui/fx-switch.js +116 -3
  50. package/src/ui/fx-trigger.js +9 -4
  51. package/src/ui/fx-upload.js +10 -4
  52. package/src/ui/repeat-base.js +20 -12
  53. package/src/withDraggability.js +10 -1
  54. package/src/xpath-evaluation.js +30 -18
  55. package/src/xpath-path.js +122 -0
  56. package/src/xpath-util.js +11 -126
  57. package/src/actions/StringTpl.js +0 -17
  58. package/src/extract-predicate-deps.js +0 -57
  59. package/src/extractPredicateDependencies.js +0 -36
  60. package/src/json/lensFromNode.js +0 -5
  61. package/src/json-util.js +0 -27
  62. package/src/tools/adi.js +0 -1111
  63. package/src/tools/deprecation.md +0 -1
  64. package/src/tools/fx-action-log.js +0 -745
  65. package/src/tools/fx-devtools.js +0 -444
  66. package/src/tools/fx-dom-inspector.js +0 -610
  67. package/src/tools/fx-json-instance.js +0 -444
  68. package/src/tools/fx-log-item.js +0 -128
  69. package/src/tools/fx-log-settings.js +0 -533
  70. package/src/tools/fx-minimap.js +0 -203
  71. package/src/tools/helpers.js +0 -132
  72. package/src/ui/TemplateExpression.js +0 -12
  73. package/src/ui/fx-dom-inspector.js +0 -1255
@@ -10,6 +10,7 @@ import { UIElement } from './UIElement.js';
10
10
  import { getPath } from '../xpath-path.js';
11
11
  import { FxModel } from '../fx-model.js';
12
12
  import { FxBind } from '../fx-bind.js';
13
+ import { DependencyNotifyingDomFacade } from '../DependencyNotifyingDomFacade.js';
13
14
 
14
15
  /**
15
16
  * `fx-repeat`
@@ -59,10 +60,38 @@ export class FxRepeat extends withDraggability(UIElement, false) {
59
60
  this.nodeset = [];
60
61
  this.inited = false;
61
62
  this.index = 1;
62
- this.repeatSize = 0;
63
63
  this.attachShadow({ mode: 'open', delegatesFocus: true });
64
64
  this.opNum = 0; // global number of operations
65
65
 
66
+ // Progressive rendering (size cap + IntersectionObserver sentinel).
67
+ // `_sentinel !== null` is the single source of truth for "a cap is active and there
68
+ // is more to reveal" - see _syncSentinels().
69
+ this._sizeLimit = Infinity;
70
+ this._renderTarget = 0;
71
+ this._sentinel = null;
72
+ this._sentinelObserver = null;
73
+
74
+ // True windowed virtualization (opt-in via `virtual` attribute + `size`).
75
+ // `_windowStart` is the 0-based logical index of the first RENDERED row; it is always 0
76
+ // when `_virtual` is false, which is what makes every `+ this._windowStart` offset below
77
+ // degenerate to exactly today's prefix-window math for non-virtual repeats.
78
+ this._virtual = false;
79
+ this._windowStart = 0;
80
+ this._topSentinel = null;
81
+ this._topSentinelObserver = null;
82
+
83
+ // Continuous scroll-driven trim (virtual mode only). The sentinels only fire once the
84
+ // window's leading/trailing edge is reached, which (given the sentinel sits at the very
85
+ // end of whatever was just rendered) forces scrolling through almost a full window's
86
+ // worth of content before a slide+evict happens - see _trimWindow() for why that alone
87
+ // lets the rendered count settle well above `size`. This listener evicts confirmed
88
+ // off-screen rows continuously as the user scrolls, so the window keeps converging on
89
+ // `size` instead of only trimming in bursts tied to sentinel crossings.
90
+ this._scrollTrimRoot = null;
91
+ this._scrollTrimHandler = null;
92
+ this._scrollTrimLastTop = 0;
93
+ this._trimScheduled = false;
94
+
66
95
  this.handleInsertHandler = null;
67
96
  this.handleDeleteHandler = null;
68
97
 
@@ -139,6 +168,37 @@ export class FxRepeat extends withDraggability(UIElement, false) {
139
168
  return true;
140
169
  }
141
170
 
171
+ // ------------------------------------------------------------
172
+ // Progressive rendering (size cap)
173
+ // ------------------------------------------------------------
174
+
175
+ _getSizeLimit() {
176
+ const attr = this.getAttribute('size');
177
+ if (!attr) return Infinity;
178
+ const n = parseInt(attr, 10);
179
+ return Number.isFinite(n) && n > 0 ? n : Infinity;
180
+ }
181
+
182
+ /**
183
+ * True windowed virtualization is opt-in via `virtual`, and only meaningful together with
184
+ * a finite `size` (which becomes the window size rather than a prefix cap). `virtual`
185
+ * without `size` degenerates to fully uncapped, exactly like `size` being absent.
186
+ */
187
+ _isVirtual() {
188
+ return this.hasAttribute('virtual') && this._getSizeLimit() !== Infinity;
189
+ }
190
+
191
+ /**
192
+ * Upper bound on how many rows _evictFromTop()/_evictFromBottom() remove in a single call.
193
+ * A quarter of `size` (floor 4) keeps a single correction small no matter how large a
194
+ * backlog a caller asks to clear - see the comment on _evictFromTop() for why an uncapped
195
+ * batch turns into a scroll-speed-proportional scrollTop jump. Any excess beyond this cap
196
+ * simply drains on the next call (there always is one while scrolling continues).
197
+ */
198
+ _maxEvictBatch() {
199
+ return Math.max(4, Math.ceil(this._sizeLimit / 4));
200
+ }
201
+
142
202
  connectedCallback() {
143
203
  super.connectedCallback();
144
204
  this.template = this.querySelector('template');
@@ -209,6 +269,7 @@ export class FxRepeat extends withDraggability(UIElement, false) {
209
269
 
210
270
  const inserted = detail.insertedNodes;
211
271
  const insertionIndex = this.nodeset.indexOf(inserted) + 1; // 1-based
272
+ const relIdx = insertionIndex - 1; // 0-based logical index of the inserted row
212
273
 
213
274
  const repeatItems = Array.from(
214
275
  this.querySelectorAll(
@@ -216,32 +277,86 @@ export class FxRepeat extends withDraggability(UIElement, false) {
216
277
  ),
217
278
  );
218
279
 
219
- const newRepeatItem = this._createNewRepeatItem();
220
-
221
- const beforeNode = repeatItems[insertionIndex - 1] ?? null;
222
- this.insertBefore(newRepeatItem, beforeNode);
223
- newRepeatItem.index = insertionIndex;
224
- this._initVariables(newRepeatItem);
225
-
226
- newRepeatItem.nodeset = inserted;
280
+ // Uncapped repeats always materialize immediately: `_renderTarget` is only resynced to
281
+ // the full nodeset length inside refresh(), which may not have run yet between two
282
+ // synchronous inserts, so it can't be trusted as a window boundary when uncapped.
283
+ // Zone B (within window): today's "within window" case, offset by `_windowStart`.
284
+ const withinWindow =
285
+ this._sizeLimit === Infinity ||
286
+ (relIdx >= this._windowStart && relIdx < this._renderTarget);
287
+ // Zone A (before window): only reachable in virtual mode - `_windowStart` is always 0
288
+ // off virtual mode, so `relIdx < 0` never holds.
289
+ const beforeWindow = this._virtual && relIdx < this._windowStart;
290
+
291
+ if (withinWindow) {
292
+ const relativePos = relIdx - this._windowStart;
293
+ const newRepeatItem = this._createNewRepeatItem();
294
+
295
+ const beforeNode = repeatItems[relativePos] ?? this._sentinel ?? null;
296
+ this.insertBefore(newRepeatItem, beforeNode);
297
+ newRepeatItem.index = insertionIndex;
298
+ this._initVariables(newRepeatItem);
299
+
300
+ newRepeatItem.nodeset = inserted;
301
+
302
+ for (let i = relativePos; i < repeatItems.length; ++i) {
303
+ repeatItems[i].index += 1;
304
+ }
227
305
 
228
- for (let i = insertionIndex - 1; i < repeatItems.length; ++i) {
229
- repeatItems[i].index += 1;
306
+ this._renderTarget += 1;
307
+ // In virtual mode, an in-window insert momentarily grows the window past `size` -
308
+ // evict one row from the opposite (bottom) end to restore window size. This is a
309
+ // local, in-place insert, not a scroll-driven slide, so no scrollTop correction is
310
+ // needed either way (known limitation: this always evicts from the bottom regardless
311
+ // of where in the window the insert landed - see plan's "known limitation" note).
312
+ if (this._virtual && this._renderTarget - this._windowStart > this._sizeLimit) {
313
+ this._evictFromBottom(1);
314
+ }
315
+ this._syncSentinels();
316
+
317
+ this.opNum++;
318
+ let parentModelItem = FxBind.createModelItem(this.ref, inserted, newRepeatItem, this.opNum);
319
+ // IMPORTANT: registerModelItem may return an existing canonical ModelItem for the same path.
320
+ // Always keep using the returned instance to avoid "ghost" ModelItems that still notify.
321
+ parentModelItem = this.getModel().registerModelItem(parentModelItem);
322
+ newRepeatItem.modelItem = parentModelItem;
323
+
324
+ this._createModelItemsRecursively(newRepeatItem, parentModelItem);
325
+
326
+ fore.scanForNewTemplateExpressionsNextRefresh();
327
+ fore.addToBatchedNotifications(newRepeatItem);
328
+ } else if (beforeWindow) {
329
+ // Zone A: inserted above the rendered window. The rendered rows are still correct
330
+ // (same node identities) - only their logical position shifted by one. No DOM
331
+ // change; just shift the window's bookkeeping to match.
332
+ this._windowStart += 1;
333
+ this._renderTarget += 1;
334
+ this._reindexRenderedRows();
335
+ this._syncSentinels();
336
+
337
+ // IMPORTANT: do NOT fall through to the shared setIndex(insertionIndex) below, and do
338
+ // NOT route the index bump through setIndex() either - both would run the
339
+ // window-membership check and, since `this.index` (still pointing wherever it did
340
+ // before this insert - raw scrolling never updates it, only setIndex() does) may now
341
+ // resolve to a position outside the window we just deliberately left untouched,
342
+ // trigger an unwanted seek-jump that destroys/rebuilds this exact window. Nothing
343
+ // rendered actually moved (Zone A never touches DOM), so only the numeric index
344
+ // needs to shift to keep pointing at the same logical row - update the attribute
345
+ // directly. Known limitation: index()-dependents elsewhere in the form aren't
346
+ // notified of this silent renumbering (no 'item-changed' dispatch) - acceptable since
347
+ // Zone A only fires for inserts above an actively-scrolled-away window, an already
348
+ // rare interaction.
349
+ if (this.index >= insertionIndex) {
350
+ this.index += 1;
351
+ }
352
+ return;
230
353
  }
354
+ // else: Zone C - inserted beyond the rendered window. this.nodeset already reflects
355
+ // the insert (re-evaluated above) - there's no DOM row to create yet. setIndex() below
356
+ // triggers growth-on-demand (non-virtual) or a seek jump (virtual) if navigation
357
+ // reaches that far.
231
358
 
232
359
  this.setIndex(insertionIndex);
233
-
234
- this.opNum++;
235
- let parentModelItem = FxBind.createModelItem(this.ref, inserted, newRepeatItem, this.opNum);
236
- // IMPORTANT: registerModelItem may return an existing canonical ModelItem for the same path.
237
- // Always keep using the returned instance to avoid "ghost" ModelItems that still notify.
238
- parentModelItem = this.getModel().registerModelItem(parentModelItem);
239
- newRepeatItem.modelItem = parentModelItem;
240
-
241
- this._createModelItemsRecursively(newRepeatItem, parentModelItem);
242
-
243
- fore.scanForNewTemplateExpressionsNextRefresh();
244
- fore.addToBatchedNotifications(newRepeatItem);
245
360
  };
246
361
 
247
362
  // ----------------
@@ -332,16 +447,36 @@ export class FxRepeat extends withDraggability(UIElement, false) {
332
447
  -webkit-animation: fade-out-bottom 0.7s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
333
448
  animation: fade-out-bottom 0.7s cubic-bezier(0.250, 0.460, 0.450, 0.940) both;
334
449
  }
450
+ /* A bare <slot> renders as display:contents (layout-transparent), so demos that put
451
+ display:flex/grid on the fx-repeat host (e.g. demo/kanban.html's #column) get their
452
+ fx-repeatitems as direct flex/grid items. The [part=list] wrapper below must stay
453
+ equally transparent or it becomes the sole flex/grid item and items collapse into a
454
+ block stack inside it. role="list" survives display:contents in all supported browsers. */
455
+ [part="list"]{
456
+ display: contents;
457
+ }
335
458
  `;
459
+ // `role="list"` sits on a wrapper around the default slot only, not on the host itself:
460
+ // a named `slot="header"` (e.g. a `<table>` used as a column header row, see demo/api.html)
461
+ // is real, non-listitem content that would otherwise become an accessibility-tree child of
462
+ // the list and fail axe's aria-required-children check. Keeping it outside the wrapper avoids
463
+ // that while still giving the repeated `fx-repeatitem`s (each `role="listitem"`) a `list`
464
+ // parent.
336
465
  const html = `
337
466
  <slot name="header"></slot>
338
- <slot></slot>
467
+ <div part="list" role="list"><slot></slot></div>
339
468
  `;
340
469
 
341
- this.shadowRoot.innerHTML = `
470
+ const sheet = Fore.getSharedStyleSheet(style);
471
+ if (sheet) {
472
+ this.shadowRoot.innerHTML = html;
473
+ this.shadowRoot.adoptedStyleSheets = [sheet];
474
+ } else {
475
+ this.shadowRoot.innerHTML = `
342
476
  <style>${style}</style>
343
477
  ${html}
344
478
  `;
479
+ }
345
480
  }
346
481
 
347
482
  /**
@@ -379,40 +514,76 @@ export class FxRepeat extends withDraggability(UIElement, false) {
379
514
  insertionIndex1 = Math.max(1, Math.min(insertionIndex1, newLen));
380
515
  const pos0 = insertionIndex1 - 1;
381
516
 
382
- // 3) Insert DOM row: set nodeset/index BEFORE inserting into DOM.
383
- const before = repeatItems();
384
- const beforeNode = before[pos0] ?? null;
517
+ // See the equivalent comment in the XML insert handler above.
518
+ const withinWindow =
519
+ this._sizeLimit === Infinity || (pos0 >= this._windowStart && pos0 < this._renderTarget);
520
+ const beforeWindow = this._virtual && pos0 < this._windowStart;
385
521
 
386
- const newRepeatItem = this._createNewRepeatItem();
387
- newRepeatItem.index = pos0 + 1;
388
- this._initVariables(newRepeatItem);
389
- newRepeatItem.nodeset = this.nodeset[pos0];
522
+ if (withinWindow) {
523
+ const relativePos = pos0 - this._windowStart;
390
524
 
391
- this.insertBefore(newRepeatItem, beforeNode);
525
+ // 3) Insert DOM row: set nodeset/index BEFORE inserting into DOM.
526
+ const before = repeatItems();
527
+ const beforeNode = before[relativePos] ?? this._sentinel ?? null;
392
528
 
393
- // Ensure it gets initialized/rendered
394
- fore.registerLazyElement(newRepeatItem);
395
- if (fore.createNodes) {
396
- fore.initData(newRepeatItem);
397
- }
398
- fore.scanForNewTemplateExpressionsNextRefresh();
399
- fore.addToBatchedNotifications(newRepeatItem);
529
+ const newRepeatItem = this._createNewRepeatItem();
530
+ newRepeatItem.index = pos0 + 1;
531
+ this._initVariables(newRepeatItem);
532
+ newRepeatItem.nodeset = this.nodeset[pos0];
400
533
 
401
- // 4) Rebind shifted rows (pos0+1..end) and refresh only those.
402
- const after = repeatItems();
534
+ this.insertBefore(newRepeatItem, beforeNode);
403
535
 
404
- for (let i = pos0 + 1; i < after.length; i++) {
405
- const ri = after[i];
406
- ri.index = i + 1;
536
+ // Ensure it gets initialized/rendered
537
+ fore.registerLazyElement(newRepeatItem);
538
+ if (fore.createNodes) {
539
+ fore.initData(newRepeatItem);
540
+ }
541
+ fore.scanForNewTemplateExpressionsNextRefresh();
542
+ fore.addToBatchedNotifications(newRepeatItem);
407
543
 
408
- const newNode = this.nodeset[i];
409
- if (ri.nodeset !== newNode) {
410
- ri.nodeset = newNode;
411
- if (fore.createNodes) fore.initData(ri);
544
+ this._renderTarget += 1;
545
+ // See the equivalent comment in the XML insert handler above.
546
+ if (this._virtual && this._renderTarget - this._windowStart > this._sizeLimit) {
547
+ this._evictFromBottom(1);
412
548
  }
549
+ this._syncSentinels();
413
550
 
414
- fore.addToBatchedNotifications(ri);
551
+ // 4) Rebind shifted rows (relativePos+1..end) and refresh only those.
552
+ const after = repeatItems();
553
+
554
+ for (let i = relativePos + 1; i < after.length; i++) {
555
+ const ri = after[i];
556
+ const logicalIdx = this._windowStart + i;
557
+ ri.index = logicalIdx + 1;
558
+
559
+ const newNode = this.nodeset[logicalIdx];
560
+ if (ri.nodeset !== newNode) {
561
+ ri.nodeset = newNode;
562
+ if (fore.createNodes) fore.initData(ri);
563
+ }
564
+
565
+ fore.addToBatchedNotifications(ri);
566
+ }
567
+ } else if (beforeWindow) {
568
+ // Zone A: see the equivalent comment in the XML insert handler above - must NOT go
569
+ // through setIndex() at all (neither for pos0+1 nor for an adjusted value), since
570
+ // `this.index` may already point outside this deliberately-untouched window (raw
571
+ // scrolling never updates it) and setIndex()'s window-membership check would treat
572
+ // that as an out-of-window navigation, triggering an unwanted seek-jump.
573
+ this._windowStart += 1;
574
+ this._renderTarget += 1;
575
+ this._reindexRenderedRows();
576
+ this._syncSentinels();
577
+
578
+ // insertionIndex1 (outer scope) already equals pos0 + 1.
579
+ if (this.index >= insertionIndex1) {
580
+ this.index += 1;
581
+ }
582
+ return;
415
583
  }
584
+ // else: Zone C - inserted beyond the rendered window - no DOM row to create, nothing to
585
+ // rebind. setIndex() below triggers growth-on-demand (non-virtual) or a seek jump
586
+ // (virtual) if navigation reaches that far.
416
587
 
417
588
  // Select inserted row
418
589
  this.setIndex(pos0 + 1);
@@ -435,6 +606,24 @@ export class FxRepeat extends withDraggability(UIElement, false) {
435
606
 
436
607
  document.removeEventListener('deleted', this.handleDeleteHandler, true);
437
608
  document.removeEventListener('insert', this.handleInsertHandler, true);
609
+
610
+ if (this._sentinelObserver) {
611
+ this._sentinelObserver.disconnect();
612
+ this._sentinelObserver = null;
613
+ }
614
+ this._sentinel = null;
615
+
616
+ if (this._topSentinelObserver) {
617
+ this._topSentinelObserver.disconnect();
618
+ this._topSentinelObserver = null;
619
+ }
620
+ this._topSentinel = null;
621
+
622
+ if (this._scrollTrimHandler && this._scrollTrimRoot) {
623
+ this._scrollTrimRoot.removeEventListener('scroll', this._scrollTrimHandler);
624
+ }
625
+ this._scrollTrimRoot = null;
626
+ this._scrollTrimHandler = null;
438
627
  }
439
628
 
440
629
  get repeatSize() {
@@ -442,14 +631,33 @@ export class FxRepeat extends withDraggability(UIElement, false) {
442
631
  }
443
632
 
444
633
  set repeatSize(size) {
445
- this.size = size;
634
+ if (!size) {
635
+ this.removeAttribute('size');
636
+ } else {
637
+ this.setAttribute('size', size);
638
+ }
446
639
  }
447
640
 
448
- setIndex(index) {
641
+ setIndex(index, notifyDependents = true) {
449
642
  this.index = index;
450
643
 
644
+ if (this._sizeLimit !== Infinity && Number.isFinite(index) && index >= 1) {
645
+ if (this._virtual) {
646
+ // Sliding window: navigation to an index outside the window can't be front-filled
647
+ // (there's no stable prefix to grow from) - it's a hard jump instead. See
648
+ // _seekWindowTo(). Navigation to an index already inside the window is a no-op here.
649
+ const logicalIdx = index - 1;
650
+ const inWindow = logicalIdx >= this._windowStart && logicalIdx < this._renderTarget;
651
+ if (!inWindow) this._seekWindowTo(logicalIdx);
652
+ } else {
653
+ // Growth-on-demand: navigation (keyboard, index('id') dependents, programmatic) to
654
+ // an index beyond the currently rendered window front-fills up through it.
655
+ this._growRenderTarget(index);
656
+ }
657
+ }
658
+
451
659
  const rItems = this.querySelectorAll(':scope > fx-repeatitem');
452
- const selected = rItems[this.index - 1];
660
+ const selected = rItems[this.index - 1 - this._windowStart];
453
661
 
454
662
  this.applyIndex(selected);
455
663
 
@@ -458,7 +666,9 @@ export class FxRepeat extends withDraggability(UIElement, false) {
458
666
  //
459
667
  // When setIndex is invoked as a reaction to a repeatitem click/focus,
460
668
  // the repeatitem already dispatched item-changed and dependents already react.
461
- if (!this._settingIndexFromItemChanged) {
669
+ // refresh() passes notifyDependents=false when neither the index nor the node at
670
+ // the index changed — a repeat refresh alone is not an index change.
671
+ if (!this._settingIndexFromItemChanged && notifyDependents) {
462
672
  this.dispatchEvent(
463
673
  new CustomEvent('item-changed', {
464
674
  composed: false,
@@ -512,7 +722,10 @@ export class FxRepeat extends withDraggability(UIElement, false) {
512
722
 
513
723
  if (mi.path.startsWith(`${parentBaseInChildStyle}_`)) return;
514
724
 
515
- mi.path = `${parentBaseInChildStyle}_${op}${mi.path.slice(parentBaseInChildStyle.length)}`;
725
+ this.getModel()._setModelItemPath(
726
+ mi,
727
+ `${parentBaseInChildStyle}_${op}${mi.path.slice(parentBaseInChildStyle.length)}`,
728
+ );
516
729
  };
517
730
 
518
731
  Array.from(parentNode.children).forEach(child => {
@@ -581,28 +794,59 @@ export class FxRepeat extends withDraggability(UIElement, false) {
581
794
  if (nextIndex1 > d1) nextIndex1 -= 1;
582
795
  }
583
796
 
797
+ // Three-zone split against [_windowStart, _renderTarget), using PRE-delete indices
798
+ // (indices0 already are pre-delete, via detail.deletedIndexes0 - no ordering hazard here,
799
+ // unlike the XML path's handleDelete()).
584
800
  const before = repeatItems();
585
- for (const idx0 of indices0) {
586
- const itemEl = before[idx0];
587
- if (!itemEl) continue;
588
-
589
- try {
590
- fore?.unRegisterLazyElement?.(itemEl);
591
- } catch (_e) {}
801
+ let windowShiftFromAbove = 0; // count of Zone-A deletions (above the window)
802
+ let renderedRemovedCount = 0; // count of Zone-B deletions (actually rendered)
592
803
 
593
- itemEl.remove();
804
+ for (const idx0 of indices0) {
805
+ if (idx0 < this._windowStart) {
806
+ // Zone A: above window - rendered rows unaffected, window floor shifts down by one.
807
+ windowShiftFromAbove += 1;
808
+ } else if (idx0 < this._renderTarget) {
809
+ // Zone B: rendered.
810
+ const domPos = idx0 - this._windowStart;
811
+ const itemEl = before[domPos];
812
+ if (itemEl) {
813
+ try {
814
+ fore?.unRegisterLazyElement?.(itemEl);
815
+ } catch (_e) {}
816
+
817
+ itemEl.remove();
818
+ renderedRemovedCount += 1;
819
+ }
820
+ }
821
+ // else: Zone C - below window, no DOM row.
594
822
  }
595
823
 
596
824
  this._evalNodeset();
597
825
 
598
- const start0 = Math.min(...indices0);
599
- const after = repeatItems();
826
+ this._windowStart = Math.max(0, this._windowStart - windowShiftFromAbove);
827
+ this._renderTarget = this._windowStart + (before.length - renderedRemovedCount);
828
+ const totalAfterDelete = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
829
+ this._renderTarget = Math.min(this._renderTarget, totalAfterDelete);
830
+
831
+ // Virtual mode: a mid-window delete is not a scroll event - the user still expects a
832
+ // full window, so backfill from the tail if more nodeset remains below.
833
+ if (this._virtual) {
834
+ const deficit = this._sizeLimit - (this._renderTarget - this._windowStart);
835
+ if (deficit > 0 && this._renderTarget < totalAfterDelete) {
836
+ this._slideWindowDown(deficit);
837
+ }
838
+ }
839
+ this._syncSentinels();
600
840
 
601
- for (let i = start0; i < after.length; i++) {
841
+ // Rebind every currently rendered row to its (possibly shifted) logical node. The
842
+ // `ri.nodeset !== newNode` guard below makes this cheap for rows that didn't move.
843
+ const after = repeatItems();
844
+ for (let i = 0; i < after.length; i++) {
602
845
  const ri = after[i];
603
- ri.index = i + 1;
846
+ const logicalIdx = this._windowStart + i;
847
+ ri.index = logicalIdx + 1;
604
848
 
605
- const newNode = Array.isArray(this.nodeset) ? this.nodeset[i] : null;
849
+ const newNode = Array.isArray(this.nodeset) ? this.nodeset[logicalIdx] : null;
606
850
  if (ri.nodeset !== newNode) {
607
851
  ri.nodeset = newNode;
608
852
  if (fore?.createNodes) fore.initData(ri);
@@ -611,19 +855,29 @@ export class FxRepeat extends withDraggability(UIElement, false) {
611
855
  fore?.addToBatchedNotifications?.(ri);
612
856
  }
613
857
 
614
- const newLen = after.length;
615
- if (newLen === 0) {
858
+ // Clamp against the LOGICAL total, not the rendered DOM count - under a size cap the two
859
+ // differ, and clamping to the rendered count would make it impossible to navigate to a
860
+ // not-yet-rendered logical index (setIndex()'s own growth-on-demand/seek-jump handles
861
+ // materializing it).
862
+ if (totalAfterDelete === 0) {
616
863
  this.setAttribute('index', '0');
617
864
  this.index = 0;
618
865
  this._removeIndexMarker?.();
619
866
  return;
620
867
  }
621
868
 
622
- nextIndex1 = Math.max(1, Math.min(nextIndex1, newLen));
869
+ nextIndex1 = Math.max(1, Math.min(nextIndex1, totalAfterDelete));
623
870
  this.setIndex(nextIndex1);
624
871
  }
625
872
 
626
873
  handleDelete(deleted) {
874
+ // Captured BEFORE _evalNodeset() runs below, so it reflects the PRE-delete logical
875
+ // position - needed to distinguish "deleted row was above the window" (Zone A) from
876
+ // "deleted row was below the window" (Zone C) when the row isn't found among rendered
877
+ // items. (_evalNodeset() already reflects the post-delete instance state by the time
878
+ // this handler runs, since the model mutation happens before the 'deleted' event fires.)
879
+ const priorIdx = Array.isArray(this.nodeset) ? this.nodeset.indexOf(deleted) : -1;
880
+
627
881
  const items = Array.from(
628
882
  this.querySelectorAll(
629
883
  ':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
@@ -632,22 +886,54 @@ export class FxRepeat extends withDraggability(UIElement, false) {
632
886
 
633
887
  this._evalNodeset();
634
888
 
889
+ const total = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
890
+
635
891
  const indexToRemove = items.findIndex(item => item.nodeset === deleted);
636
892
  if (indexToRemove === -1) {
893
+ if (this._virtual && priorIdx >= 0 && priorIdx < this._windowStart) {
894
+ // Zone A: deleted row was above the window - rendered rows unaffected (same node
895
+ // identities), only their logical position shifted down by one. No DOM change;
896
+ // shift the window's bookkeeping and current index to match (mirrors the XML
897
+ // insert handler's Zone-A shift, in reverse). Deliberately does NOT call setIndex()
898
+ // - see the equivalent comment on the insert side re: `this.index` possibly already
899
+ // being outside this deliberately-untouched window (raw scrolling never updates it),
900
+ // which would make setIndex()'s window-membership check trigger an unwanted seek.
901
+ this._windowStart = Math.max(0, this._windowStart - 1);
902
+ this._renderTarget = Math.max(this._windowStart, this._renderTarget - 1);
903
+ this._reindexRenderedRows();
904
+ this._syncSentinels();
905
+ this.index = Math.max(1, this.index - 1);
906
+ return;
907
+ }
908
+ // Zone C (or non-virtual beyond-window): deleted node was never rendered - no DOM row
909
+ // to remove, but the window ceiling may now exceed the shrunk nodeset.
910
+ this._renderTarget = Math.min(this._renderTarget, total);
911
+ this._syncSentinels();
637
912
  return;
638
913
  }
914
+
915
+ // Zone B: rendered.
639
916
  const itemToRemove = items[indexToRemove];
640
917
  itemToRemove.remove();
641
918
 
642
- const newLength = this.querySelectorAll(
643
- ':scope > fx-repeat-item, :scope > fx-repeatitem, :scope > .repeat-item',
644
- ).length;
919
+ this._renderTarget = Math.max(this._windowStart, this._renderTarget - 1);
920
+ // Virtual mode: a mid-window delete is not a scroll event - the user still expects a
921
+ // full window, so backfill from the tail if more nodeset remains below.
922
+ if (this._virtual) {
923
+ const deficit = this._sizeLimit - (this._renderTarget - this._windowStart);
924
+ if (deficit > 0 && this._renderTarget < total) {
925
+ this._slideWindowDown(deficit);
926
+ }
927
+ }
928
+ this._syncSentinels();
645
929
 
646
- let nextIndex = indexToRemove + 1;
647
- if (newLength === 0) {
930
+ // Clamp against the LOGICAL total, not the rendered DOM count - see the equivalent
931
+ // comment in _handleJsonDeleted above.
932
+ let nextIndex = this._windowStart + indexToRemove + 1;
933
+ if (total === 0) {
648
934
  nextIndex = 0;
649
- } else if (nextIndex > newLength) {
650
- nextIndex = newLength;
935
+ } else if (nextIndex > total) {
936
+ nextIndex = total;
651
937
  }
652
938
 
653
939
  this.setIndex(nextIndex);
@@ -666,6 +952,592 @@ export class FxRepeat extends withDraggability(UIElement, false) {
666
952
  return newItem;
667
953
  }
668
954
 
955
+ _materializeRepeatItem(position) {
956
+ const newItem = this._createNewRepeatItem();
957
+ this.insertBefore(newItem, this._sentinel || null);
958
+ this._initVariables(newItem);
959
+ newItem.nodeset = this.nodeset[position - 1];
960
+ newItem.index = position;
961
+ return newItem;
962
+ }
963
+
964
+ /**
965
+ * Grows the rendered window up to (at most) `desired` logical positions, materializing
966
+ * any not-yet-rendered rows in between. Used by the sentinel intersection callback and
967
+ * by setIndex() to front-fill on-demand navigation beyond the current window.
968
+ */
969
+ _growRenderTarget(desired) {
970
+ const total = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
971
+ const newTarget = Math.max(this._renderTarget, Math.min(desired, total));
972
+ if (newTarget === this._renderTarget) return;
973
+
974
+ const fore = this.getOwnerForm();
975
+ for (let position = this._renderTarget + 1; position <= newTarget; position += 1) {
976
+ const newItem = this._materializeRepeatItem(position);
977
+ if (fore.createNodes) {
978
+ fore.initData(newItem);
979
+ }
980
+ fore.scanForNewTemplateExpressionsNextRefresh();
981
+ fore.registerLazyElement(newItem);
982
+ newItem.refresh(true);
983
+ Fore.dispatch(this, 'item-created', { nodeset: newItem.nodeset, pos: position });
984
+ }
985
+ this._renderTarget = newTarget;
986
+ this._syncSentinels();
987
+ }
988
+
989
+ /**
990
+ * Renumbers .index on every currently rendered row to match its logical position
991
+ * (_windowStart + DOM offset). Needed after any operation that shifts _windowStart
992
+ * without individually touching every row's index (prepend, eviction, Zone-A shifts).
993
+ */
994
+ _reindexRenderedRows() {
995
+ const rows = this.querySelectorAll(':scope > fx-repeatitem');
996
+ rows.forEach((row, i) => {
997
+ row.index = this._windowStart + i + 1;
998
+ });
999
+ }
1000
+
1001
+ /**
1002
+ * Evicts up to `count` rows from the top of the rendered window, correcting scrollTop by
1003
+ * the EXACT measured height of the removed rows (not an estimate) so the still-visible
1004
+ * content doesn't visually jump. Only meaningful in virtual mode.
1005
+ *
1006
+ * Only rows CONFIRMED already scrolled a safe margin above the visible viewport are
1007
+ * evicted, even if that's fewer than `count` - a bottom-sentinel intersection only proves
1008
+ * the user has scrolled near the bottom of the currently rendered content, not that
1009
+ * they've scrolled past every row in the leading edge. Evicting unconditionally would
1010
+ * remove rows the user is still looking at, forcing a scrollTop correction bigger than the
1011
+ * current scroll position (clamped to 0) and visibly yanking the viewport.
1012
+ *
1013
+ * The `_EVICT_MARGIN_PX` buffer is what keeps the freshly-created top sentinel (inserted
1014
+ * right after eviction, at the new window's start) genuinely off-screen rather than
1015
+ * exactly at the viewport boundary: without it, the scrollTop correction above would land
1016
+ * precisely where content ends and the new sentinel begins, and the sentinel's very first
1017
+ * IntersectionObserver reading (which reports the CURRENT state, not just future changes)
1018
+ * would immediately report it as visible - triggering an instant slide back up and
1019
+ * oscillating forever. Leaving this margin of already-scrolled-past content unevicted
1020
+ * means the sentinel ends up genuinely above the fold, only intersecting once the user
1021
+ * scrolls up for real.
1022
+ */
1023
+ _evictFromTop(count) {
1024
+ const EVICT_MARGIN_PX = 100;
1025
+ // Bounded regardless of how large a deficit the caller asks to clear in one go: a fast
1026
+ // scroll (or a long stretch since the last trim) can hand this a large `count`, and since
1027
+ // eviction here is corrected by an immediate scrollTop jump of the exact removed height,
1028
+ // an uncapped batch means an uncapped, scroll-speed-proportional jump - the faster you
1029
+ // scroll, the heftier the correction. Capping the batch keeps each single correction small
1030
+ // and roughly constant; any remainder simply drains on the next trim/slide call (there's
1031
+ // always a next one while scrolling continues, via _trimWindow's own scroll listener).
1032
+ const boundedCount = Math.min(count, this._maxEvictBatch());
1033
+ const scrollRoot =
1034
+ this._findSentinelRoot() || document.scrollingElement || document.documentElement;
1035
+ const containerTop =
1036
+ scrollRoot && scrollRoot.getBoundingClientRect ? scrollRoot.getBoundingClientRect().top : 0;
1037
+
1038
+ const candidates = Array.from(this.querySelectorAll(':scope > fx-repeatitem')).slice(
1039
+ 0,
1040
+ boundedCount,
1041
+ );
1042
+ const rows = [];
1043
+ for (const row of candidates) {
1044
+ if (row.getBoundingClientRect().bottom <= containerTop - EVICT_MARGIN_PX) rows.push(row);
1045
+ else break;
1046
+ }
1047
+ if (rows.length === 0) return;
1048
+
1049
+ const removedHeight =
1050
+ rows[rows.length - 1].getBoundingClientRect().bottom - rows[0].getBoundingClientRect().top;
1051
+ // Captured BEFORE removal: removing the rows shrinks scrollHeight immediately, and the
1052
+ // browser auto-clamps scrollTop to the new (smaller) max as a side effect of that DOM
1053
+ // mutation - reading scrollTop again afterward (e.g. via `-=`) would read the
1054
+ // ALREADY-CLAMPED value and double-subtract from it, overshooting past 0.
1055
+ const scrollTopBeforeRemoval = scrollRoot ? scrollRoot.scrollTop : 0;
1056
+
1057
+ const fore = this.getOwnerForm();
1058
+ rows.forEach(row => {
1059
+ fore.unRegisterLazyElement(row);
1060
+ row.remove();
1061
+ });
1062
+
1063
+ this._windowStart += rows.length;
1064
+ if (scrollRoot) scrollRoot.scrollTop = scrollTopBeforeRemoval - removedHeight;
1065
+ this._reindexRenderedRows();
1066
+ }
1067
+
1068
+ /**
1069
+ * Evicts up to `count` rows from the bottom of the rendered window. Symmetric safety check
1070
+ * to _evictFromTop: only rows CONFIRMED already below the visible viewport are evicted, so
1071
+ * a row the user is currently looking at never disappears out from under them. No scrollTop
1072
+ * correction is needed here - removing confirmed-below-the-fold content doesn't shift what's
1073
+ * currently shown.
1074
+ */
1075
+ _evictFromBottom(count) {
1076
+ // See the equivalent comment in _evictFromTop - same batch cap, kept symmetric even
1077
+ // though this particular eviction needs no scrollTop correction, so a single very large
1078
+ // batch here is cheap on its own; the cap mainly keeps top/bottom behavior consistent.
1079
+ const boundedCount = Math.min(count, this._maxEvictBatch());
1080
+ const scrollRoot =
1081
+ this._findSentinelRoot() || document.scrollingElement || document.documentElement;
1082
+ // scrollRoot always resolves to a real element (document.documentElement is the final
1083
+ // fallback), so this always has a getBoundingClientRect - no further fallback needed.
1084
+ const containerBottom = scrollRoot.getBoundingClientRect().bottom;
1085
+
1086
+ const allRows = Array.from(this.querySelectorAll(':scope > fx-repeatitem'));
1087
+ const candidates = allRows.slice(-boundedCount);
1088
+ const rows = [];
1089
+ for (let i = candidates.length - 1; i >= 0; i -= 1) {
1090
+ const row = candidates[i];
1091
+ if (row.getBoundingClientRect().top >= containerBottom) rows.unshift(row);
1092
+ else break;
1093
+ }
1094
+ if (rows.length === 0) return;
1095
+
1096
+ const fore = this.getOwnerForm();
1097
+ rows.forEach(row => {
1098
+ fore.unRegisterLazyElement(row);
1099
+ row.remove();
1100
+ });
1101
+
1102
+ this._renderTarget -= rows.length;
1103
+ }
1104
+
1105
+ /**
1106
+ * Virtual-mode bottom-sentinel response: appends the next `count` logical rows below the
1107
+ * current window, then evicts from the top to bring the rendered count back down to
1108
+ * `_sizeLimit`. The evict count is derived (not assumed equal to `count`) because the
1109
+ * window may not have reached full size yet (e.g. very first slide after initial load).
1110
+ */
1111
+ _slideWindowDown(count) {
1112
+ const total = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
1113
+ const oldEnd = this._renderTarget;
1114
+ const newEnd = Math.min(oldEnd + count, total);
1115
+ if (newEnd === oldEnd) return;
1116
+
1117
+ const fore = this.getOwnerForm();
1118
+ for (let logicalIdx = oldEnd; logicalIdx < newEnd; logicalIdx += 1) {
1119
+ const newItem = this._materializeRepeatItem(logicalIdx + 1);
1120
+ if (fore.createNodes) fore.initData(newItem);
1121
+ fore.scanForNewTemplateExpressionsNextRefresh();
1122
+ fore.registerLazyElement(newItem);
1123
+ newItem.refresh(true);
1124
+ Fore.dispatch(this, 'item-created', { nodeset: newItem.nodeset, pos: logicalIdx + 1 });
1125
+ }
1126
+ this._renderTarget = newEnd;
1127
+
1128
+ // Deliberately NOT capped at `newEnd - oldEnd` (the count just added): a previous slide
1129
+ // may have under-evicted (its rows weren't yet confirmed off-screen per _evictFromTop's
1130
+ // safety margin), leaving a backlog above `_sizeLimit`. Requesting the full deficit here
1131
+ // lets that backlog catch up once those rows really are off-screen, instead of the excess
1132
+ // being locked in forever at whatever a single partial eviction left behind.
1133
+ const currentRendered = this._renderTarget - this._windowStart;
1134
+ const evictCount = Math.max(0, currentRendered - this._sizeLimit);
1135
+ if (evictCount > 0) this._evictFromTop(evictCount);
1136
+
1137
+ this._syncSentinels();
1138
+ }
1139
+
1140
+ /**
1141
+ * Virtual-mode top-sentinel response: prepends the previous `count` logical rows above the
1142
+ * current window, then evicts from the bottom to bring the rendered count back down to
1143
+ * `_sizeLimit`. Rows are created in ASCENDING logical order and inserted before a single
1144
+ * anchor node captured once before the loop - this builds correct DOM order in one pass
1145
+ * without recomputing the anchor (e.g. via repeated querySelector) on every iteration.
1146
+ *
1147
+ * Prepending content ABOVE the current scroll position pushes the previously-visible
1148
+ * content further down the document without changing scrollTop, which would otherwise
1149
+ * make the viewport appear to jump to different (earlier) content. Corrected the same way
1150
+ * _evictFromTop() corrects for removal: capture scrollTop BEFORE the mutation, measure the
1151
+ * EXACT height of what was just inserted, and set the new absolute scrollTop afterward
1152
+ * (not `+=`, which - mirroring the eviction fix - could read a value already perturbed by
1153
+ * the DOM mutation itself).
1154
+ */
1155
+ _slideWindowUp(count) {
1156
+ const oldStart = this._windowStart;
1157
+ const target = Math.max(0, oldStart - count);
1158
+ if (target === oldStart) return;
1159
+
1160
+ // Bounded the same way _evictFromTop()/_evictFromBottom() are (see _maxEvictBatch()): a
1161
+ // single sentinel trigger requests sliding back a whole `size` worth at once, which would
1162
+ // otherwise prepend that many rows and correct scrollTop forward by their full combined
1163
+ // height in one jump. Prepending (and correcting for) only a small batch per call keeps
1164
+ // any single jump small; the remainder is picked up by the requestAnimationFrame
1165
+ // continuation below, converging to the same end state over a few frames instead of one.
1166
+ const batch = Math.min(oldStart - target, this._maxEvictBatch());
1167
+ const newStart = oldStart - batch;
1168
+
1169
+ const scrollRoot =
1170
+ this._findSentinelRoot() || document.scrollingElement || document.documentElement;
1171
+ const scrollTopBeforeInsert = scrollRoot ? scrollRoot.scrollTop : 0;
1172
+
1173
+ const fore = this.getOwnerForm();
1174
+ const anchor =
1175
+ (this._topSentinel && this._topSentinel.nextSibling) ||
1176
+ this.firstElementChild ||
1177
+ this._sentinel ||
1178
+ null;
1179
+
1180
+ const insertedItems = [];
1181
+ for (let logicalIdx = newStart; logicalIdx < oldStart; logicalIdx += 1) {
1182
+ const newItem = this._createNewRepeatItem();
1183
+ this.insertBefore(newItem, anchor);
1184
+ this._initVariables(newItem);
1185
+ newItem.nodeset = this.nodeset[logicalIdx];
1186
+ newItem.index = logicalIdx + 1;
1187
+ if (fore.createNodes) fore.initData(newItem);
1188
+ fore.scanForNewTemplateExpressionsNextRefresh();
1189
+ fore.registerLazyElement(newItem);
1190
+ newItem.refresh(true);
1191
+ Fore.dispatch(this, 'item-created', { nodeset: newItem.nodeset, pos: logicalIdx + 1 });
1192
+ insertedItems.push(newItem);
1193
+ }
1194
+ this._windowStart = newStart;
1195
+ this._reindexRenderedRows();
1196
+
1197
+ if (insertedItems.length > 0 && scrollRoot) {
1198
+ const insertedHeight =
1199
+ insertedItems[insertedItems.length - 1].getBoundingClientRect().bottom -
1200
+ insertedItems[0].getBoundingClientRect().top;
1201
+ scrollRoot.scrollTop = scrollTopBeforeInsert + insertedHeight;
1202
+ }
1203
+
1204
+ // See the equivalent comment in _slideWindowDown - deliberately not capped at
1205
+ // `oldStart - newStart` so a prior partial eviction's backlog can catch up here too.
1206
+ const currentRendered = this._renderTarget - this._windowStart;
1207
+ const evictCount = Math.max(0, currentRendered - this._sizeLimit);
1208
+ if (evictCount > 0) this._evictFromBottom(evictCount);
1209
+
1210
+ this._syncSentinels();
1211
+
1212
+ // Batch was capped above (`batch < oldStart - target`): more prepending was requested
1213
+ // than this call actually performed, so continue on the next frame instead of waiting for
1214
+ // the top sentinel to re-cross (it may not - it's likely still off-screen after only a
1215
+ // partial slide, so no new IntersectionObserver trigger would ever arrive on its own).
1216
+ const remaining = newStart - target;
1217
+ if (remaining > 0) {
1218
+ requestAnimationFrame(() => this._slideWindowUp(remaining));
1219
+ }
1220
+ }
1221
+
1222
+ /**
1223
+ * Hard jump: destroys the entire current window and renders a fresh one starting at
1224
+ * `logicalIdx` (start-at-target, not center-on-target - simpler, matches the roadmap's
1225
+ * wording, and avoids asymmetric clamping near either end of the nodeset that centering
1226
+ * would require anyway), then resets scroll position to the top of the fresh window. Used
1227
+ * when navigation (setIndex, an index('id') dependent, an insert/append past the window)
1228
+ * targets a logical index outside the current window - no smooth-scroll illusion, no
1229
+ * incremental front-fill (that's Phase-1's answer and doesn't fit a sliding window).
1230
+ */
1231
+ _seekWindowTo(logicalIdx) {
1232
+ const total = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
1233
+ const size = this._sizeLimit;
1234
+
1235
+ const newStart = Math.max(0, Math.min(logicalIdx, Math.max(0, total - size)));
1236
+ const newEnd = Math.min(total, newStart + size);
1237
+
1238
+ const fore = this.getOwnerForm();
1239
+ this.querySelectorAll(':scope > fx-repeatitem').forEach(item => {
1240
+ fore.unRegisterLazyElement(item);
1241
+ item.remove();
1242
+ });
1243
+ if (this._topSentinel) this._destroyTopSentinel();
1244
+ if (this._sentinel) this._destroySentinel();
1245
+
1246
+ this._windowStart = newStart;
1247
+ this._renderTarget = newEnd;
1248
+ for (let li = newStart; li < newEnd; li += 1) {
1249
+ const newItem = this._materializeRepeatItem(li + 1);
1250
+ if (fore.createNodes) fore.initData(newItem);
1251
+ fore.scanForNewTemplateExpressionsNextRefresh();
1252
+ fore.registerLazyElement(newItem);
1253
+ newItem.refresh(true);
1254
+ Fore.dispatch(this, 'item-created', { nodeset: newItem.nodeset, pos: li + 1 });
1255
+ }
1256
+ this._syncSentinels();
1257
+
1258
+ const scrollRoot =
1259
+ this._findSentinelRoot() || document.scrollingElement || document.documentElement;
1260
+ if (scrollRoot) scrollRoot.scrollTop = 0;
1261
+ }
1262
+
1263
+ _createSentinel() {
1264
+ const sentinel = document.createElement('div');
1265
+ sentinel.className = 'fx-repeat-sentinel';
1266
+ sentinel.setAttribute('aria-hidden', 'true');
1267
+ // Needs a real box for IntersectionObserver geometry, so it can't be display:contents
1268
+ // like fx-repeat/fx-repeatitem typically are in grid/flex host layouts. Kept visually
1269
+ // negligible; host pages using CSS grid directly on fx-repeat's light DOM may need to
1270
+ // give `.fx-repeat-sentinel` a `grid-column` override.
1271
+ sentinel.style.cssText = 'height:1px;pointer-events:none;';
1272
+ this.appendChild(sentinel);
1273
+ this._sentinel = sentinel;
1274
+
1275
+ if (!this._sentinelObserver) {
1276
+ // An explicit root pointing at the nearest scrollable ancestor, not root:null (the
1277
+ // implicit viewport), is required: root:null does not reliably re-fire when only a
1278
+ // *nested* scrollable container scrolls (verified reproducible in headless Chrome and
1279
+ // Electron - the top-level viewport itself never changes, so the browser doesn't
1280
+ // always re-evaluate intersection for a target clipped by an inner scroll container).
1281
+ // Falls back to null when no scrollable ancestor exists, i.e. the whole page scrolls.
1282
+ this._sentinelObserver = new IntersectionObserver(
1283
+ entries => this._onSentinelIntersect(entries),
1284
+ {
1285
+ root: this._findSentinelRoot(),
1286
+ rootMargin: '0px',
1287
+ threshold: 0,
1288
+ },
1289
+ );
1290
+ }
1291
+ this._sentinelObserver.observe(sentinel);
1292
+ }
1293
+
1294
+ _findSentinelRoot() {
1295
+ let node = this.parentNode;
1296
+ while (node) {
1297
+ if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE && node.host) {
1298
+ node = node.host;
1299
+ continue;
1300
+ }
1301
+ if (node.nodeType !== Node.ELEMENT_NODE || node === document.documentElement) {
1302
+ node = node.parentNode;
1303
+ continue;
1304
+ }
1305
+ const style = getComputedStyle(node);
1306
+ if (/(auto|scroll)/.test(style.overflowY) || /(auto|scroll)/.test(style.overflow)) {
1307
+ return node;
1308
+ }
1309
+ node = node.parentNode;
1310
+ }
1311
+ return null;
1312
+ }
1313
+
1314
+ _destroySentinel() {
1315
+ if (this._sentinelObserver && this._sentinel) {
1316
+ this._sentinelObserver.unobserve(this._sentinel);
1317
+ }
1318
+ if (this._sentinel) {
1319
+ this._sentinel.remove();
1320
+ this._sentinel = null;
1321
+ }
1322
+ }
1323
+
1324
+ /**
1325
+ * Leading sentinel, symmetric to _createSentinel()/_destroySentinel() but placed as the
1326
+ * first child instead of appended, and only ever active in virtual (windowed) mode - it
1327
+ * signals "there is more nodeset above the rendered window", triggering a prepend+evict-
1328
+ * from-bottom slide instead of the trailing sentinel's append+evict-from-top slide.
1329
+ */
1330
+ _createTopSentinel() {
1331
+ const sentinel = document.createElement('div');
1332
+ sentinel.className = 'fx-repeat-sentinel fx-repeat-sentinel-top';
1333
+ sentinel.setAttribute('aria-hidden', 'true');
1334
+ sentinel.style.cssText = 'height:1px;pointer-events:none;';
1335
+ this.insertBefore(sentinel, this.firstElementChild || null);
1336
+ this._topSentinel = sentinel;
1337
+
1338
+ if (!this._topSentinelObserver) {
1339
+ this._topSentinelObserver = new IntersectionObserver(
1340
+ entries => this._onTopSentinelIntersect(entries),
1341
+ {
1342
+ root: this._findSentinelRoot(),
1343
+ rootMargin: '0px',
1344
+ threshold: 0,
1345
+ },
1346
+ );
1347
+ }
1348
+ this._topSentinelObserver.observe(sentinel);
1349
+ }
1350
+
1351
+ _destroyTopSentinel() {
1352
+ if (this._topSentinelObserver && this._topSentinel) {
1353
+ this._topSentinelObserver.unobserve(this._topSentinel);
1354
+ }
1355
+ if (this._topSentinel) {
1356
+ this._topSentinel.remove();
1357
+ this._topSentinel = null;
1358
+ }
1359
+ }
1360
+
1361
+ /**
1362
+ * Ensures the trailing sentinel exists iff a size cap is active and there are still
1363
+ * unrendered rows left, and (virtual mode only) the leading sentinel exists iff the window
1364
+ * has scrolled past the start of the nodeset. Called after every operation that can change
1365
+ * _renderTarget, _windowStart, or the nodeset length.
1366
+ */
1367
+ _syncSentinels() {
1368
+ this._syncBottomSentinel();
1369
+ this._syncTopSentinel();
1370
+ this._ensureScrollTrimListener();
1371
+ }
1372
+
1373
+ /**
1374
+ * Suppresses the browser's native overscroll/rubber-band bounce on the actual scrolling
1375
+ * ancestor, for ANY size-capped repeat - progressive rendering included, not just `virtual`.
1376
+ * Both flavors can find themselves genuinely at the end of currently-rendered content while
1377
+ * the user is still actively (momentum-)scrolling toward it - virtual mode via eviction
1378
+ * shrinking the scrollable height out from under the scroll, progressive rendering simply
1379
+ * because the next chunk hasn't been appended yet when the sentinel fires - and either one
1380
+ * can trigger a visible bounce (briefly showing whatever is behind the container) right at
1381
+ * that edge. This can't live in a shared stylesheet - the scrolling ancestor is whatever
1382
+ * element the HOST PAGE gave `overflow: auto/scroll`, under any class name, so there's no
1383
+ * fixed selector to hang it on. Setting it here, on the same element _findSentinelRoot()
1384
+ * already resolves for virtual mode's scrollTop corrections, makes it automatic for any
1385
+ * capped repeat regardless of the page's own markup - no CSS to remember. Deliberately
1386
+ * excludes the whole-page fallback (no nested scrollable ancestor found): suppressing
1387
+ * overscroll for the entire document would be a surprising side effect on a page that also
1388
+ * scrolls for reasons unrelated to this repeat. Also skipped if the page already set this
1389
+ * explicitly - never override an intentional choice.
1390
+ */
1391
+ _ensureOverscrollFix() {
1392
+ if (this._sizeLimit === Infinity) return;
1393
+
1394
+ const root = this._findSentinelRoot();
1395
+ if (root && getComputedStyle(root).overscrollBehaviorY === 'auto') {
1396
+ root.style.overscrollBehavior = 'none';
1397
+ }
1398
+ }
1399
+
1400
+ /**
1401
+ * Lazily attaches a 'scroll' listener on the nearest scrollable ancestor (virtual mode
1402
+ * only) that keeps trimming the rendered window toward `size` as the user scrolls - see
1403
+ * _trimWindow(). Re-attaches if the scrollable ancestor changes (e.g. repeat reparented)
1404
+ * and detaches entirely once virtual mode is off.
1405
+ */
1406
+ _ensureScrollTrimListener() {
1407
+ this._ensureOverscrollFix();
1408
+
1409
+ if (!this._virtual) {
1410
+ if (this._scrollTrimHandler && this._scrollTrimRoot) {
1411
+ this._scrollTrimRoot.removeEventListener('scroll', this._scrollTrimHandler);
1412
+ }
1413
+ this._scrollTrimRoot = null;
1414
+ this._scrollTrimHandler = null;
1415
+ return;
1416
+ }
1417
+
1418
+ const root = this._findSentinelRoot() || document.scrollingElement || document.documentElement;
1419
+ if (root === this._scrollTrimRoot && this._scrollTrimHandler) return;
1420
+
1421
+ if (this._scrollTrimHandler && this._scrollTrimRoot) {
1422
+ this._scrollTrimRoot.removeEventListener('scroll', this._scrollTrimHandler);
1423
+ }
1424
+
1425
+ this._scrollTrimRoot = root;
1426
+ this._scrollTrimLastTop = root ? root.scrollTop : 0;
1427
+ this._scrollTrimHandler = () => {
1428
+ if (this._trimScheduled) return;
1429
+ this._trimScheduled = true;
1430
+ requestAnimationFrame(() => {
1431
+ this._trimScheduled = false;
1432
+ const cur = this._scrollTrimRoot ? this._scrollTrimRoot.scrollTop : 0;
1433
+ const direction = cur - this._scrollTrimLastTop;
1434
+ this._scrollTrimLastTop = cur;
1435
+ this._trimWindow(direction);
1436
+ });
1437
+ };
1438
+ root.addEventListener('scroll', this._scrollTrimHandler, { passive: true });
1439
+ }
1440
+
1441
+ /**
1442
+ * Evicts whatever is currently safely off-screen (top or bottom, via the same
1443
+ * margin-guarded checks _slideWindowDown()/_slideWindowUp() already use) whenever the
1444
+ * rendered count exceeds `size`. Sentinels alone only trigger a slide once the window's
1445
+ * edge is reached - by which point almost a full window of content has already scrolled
1446
+ * by - so the rendered count settles at roughly `size + one viewport's worth` instead of
1447
+ * `size`. Running this continuously on scroll lets the excess drain away in small
1448
+ * increments as it becomes confirmed off-screen, well before the next sentinel crossing.
1449
+ *
1450
+ * `direction` (signed scrollTop delta since the last call, 0 if unknown) picks a SINGLE
1451
+ * side to evict from, matching which side is actually accumulating stale rows: scrolling
1452
+ * down (positive) only makes rows above stale, scrolling up (negative) only makes rows
1453
+ * below stale. Trying both sides unconditionally is wrong, not just redundant - while
1454
+ * scrolling up specifically to shrink `_windowStart` back toward 0 (e.g. _slideWindowUp
1455
+ * prepending rows), any leftover excess (from _evictFromBottom's own per-call batch cap
1456
+ * not yet having caught up) would otherwise get partially evicted from the top here,
1457
+ * incrementing `_windowStart` right back up and undoing part of the very prepend that
1458
+ * just ran - directly fighting the scroll-up convergence instead of assisting it.
1459
+ */
1460
+ _trimWindow(direction = 0) {
1461
+ if (!this._virtual) return;
1462
+
1463
+ let excess = this._renderTarget - this._windowStart - this._sizeLimit;
1464
+ if (excess <= 0) return;
1465
+
1466
+ let evictedFromTop = 0;
1467
+ let evictedFromBottom = 0;
1468
+
1469
+ if (direction < 0) {
1470
+ const endBefore = this._renderTarget;
1471
+ this._evictFromBottom(excess);
1472
+ evictedFromBottom = endBefore - this._renderTarget;
1473
+ excess -= evictedFromBottom;
1474
+ } else {
1475
+ const startBefore = this._windowStart;
1476
+ this._evictFromTop(excess);
1477
+ evictedFromTop = this._windowStart - startBefore;
1478
+ excess -= evictedFromTop;
1479
+ }
1480
+
1481
+ this._syncSentinels();
1482
+
1483
+ // `_evictFromTop`/`_evictFromBottom` cap themselves to a small batch per call (see
1484
+ // _maxEvictBatch()), so a large backlog (e.g. after a very fast scroll) needs several
1485
+ // passes to fully drain. While there's more to evict AND this pass actually made
1486
+ // progress, keep draining on the next animation frame rather than waiting for another
1487
+ // 'scroll' event that may never come if the user has already stopped scrolling. Stop
1488
+ // once a pass evicts nothing - the remainder isn't confirmed off-screen yet, and will
1489
+ // be picked up by the scroll listener once it is.
1490
+ if (excess > 0 && (evictedFromTop > 0 || evictedFromBottom > 0)) {
1491
+ requestAnimationFrame(() => this._trimWindow(direction));
1492
+ }
1493
+ }
1494
+
1495
+ _syncBottomSentinel() {
1496
+ const total = Array.isArray(this.nodeset) ? this.nodeset.length : 0;
1497
+ const needsSentinel = this._sizeLimit !== Infinity && this._renderTarget < total;
1498
+
1499
+ if (needsSentinel && !this._sentinel) {
1500
+ this._createSentinel();
1501
+ } else if (!needsSentinel && this._sentinel) {
1502
+ this._destroySentinel();
1503
+ } else if (needsSentinel && this._sentinel && this.lastElementChild !== this._sentinel) {
1504
+ this.appendChild(this._sentinel);
1505
+ }
1506
+ }
1507
+
1508
+ /**
1509
+ * Only ever active in virtual mode (`_windowStart` is always 0 off virtual mode, so
1510
+ * `needsTop` is always false there) - signals there is unrendered nodeset above the window.
1511
+ */
1512
+ _syncTopSentinel() {
1513
+ const needsTop = this._virtual && this._windowStart > 0;
1514
+
1515
+ if (needsTop && !this._topSentinel) {
1516
+ this._createTopSentinel();
1517
+ } else if (!needsTop && this._topSentinel) {
1518
+ this._destroyTopSentinel();
1519
+ } else if (needsTop && this._topSentinel && this.firstElementChild !== this._topSentinel) {
1520
+ this.insertBefore(this._topSentinel, this.firstElementChild);
1521
+ }
1522
+ }
1523
+
1524
+ _onSentinelIntersect(entries) {
1525
+ const last = entries[entries.length - 1];
1526
+ if (!last || !last.isIntersecting) return;
1527
+ if (this._virtual) {
1528
+ this._slideWindowDown(this._sizeLimit);
1529
+ } else {
1530
+ this._growRenderTarget(this._renderTarget + this._sizeLimit);
1531
+ }
1532
+ }
1533
+
1534
+ _onTopSentinelIntersect(entries) {
1535
+ const last = entries[entries.length - 1];
1536
+ if (!last || !last.isIntersecting) return;
1537
+ if (this._windowStart <= 0) return;
1538
+ this._slideWindowUp(this._sizeLimit);
1539
+ }
1540
+
669
1541
  init() {
670
1542
  this._evalNodeset();
671
1543
  this._initTemplate();
@@ -944,7 +1816,18 @@ export class FxRepeat extends withDraggability(UIElement, false) {
944
1816
  });
945
1817
  }
946
1818
 
947
- const rawNodeset = evaluateXPath(this.ref, inscope, this);
1819
+ let touchedNodes = null;
1820
+ let domFacade = null;
1821
+ if (this._refNeedsDependencyTracking()) {
1822
+ touchedNodes = new Set();
1823
+ domFacade = new DependencyNotifyingDomFacade(node => touchedNodes.add(node));
1824
+ }
1825
+
1826
+ const rawNodeset = evaluateXPath(this.ref, inscope, this, {}, {}, domFacade);
1827
+
1828
+ if (touchedNodes) {
1829
+ this._trackRefDependencies(touchedNodes);
1830
+ }
948
1831
 
949
1832
  this._observeJsonPredicateDependencies(inscope);
950
1833
 
@@ -973,7 +1856,17 @@ export class FxRepeat extends withDraggability(UIElement, false) {
973
1856
  async refresh(force) {
974
1857
  if (!this.inited) this.init();
975
1858
 
1859
+ const prevNodeset = this.nodeset;
1860
+
976
1861
  this._evalNodeset();
1862
+ this._sizeLimit = this._getSizeLimit();
1863
+
1864
+ // Captured BEFORE any forcing/reclamping below, so it reflects what the DOM is
1865
+ // currently windowed to - used afterward to detect the rare case where the floor
1866
+ // itself needs to move (virtual toggled off, or the tail shrank out from under it).
1867
+ const oldWindowStart = this._windowStart;
1868
+ this._virtual = this._isVirtual();
1869
+ if (!this._virtual) this._windowStart = 0;
977
1870
 
978
1871
  let repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
979
1872
  let repeatItemCount = repeatItems.length;
@@ -983,10 +1876,59 @@ export class FxRepeat extends withDraggability(UIElement, false) {
983
1876
  nodeCount = this.nodeset.length;
984
1877
  }
985
1878
 
986
- const contextSize = nodeCount;
1879
+ const total = nodeCount;
1880
+ const prevTotal = Array.isArray(prevNodeset) ? prevNodeset.length : 1;
1881
+
1882
+ // Recompute the render target:
1883
+ // - uncapped: always track the full nodeset (identical to the pre-cap `contextSize`)
1884
+ // - capped, non-virtual, nodeset grew since the last refresh cycle: this only happens
1885
+ // through a path that didn't already update _renderTarget itself (e.g. bulk/direct
1886
+ // instance mutation rather than an insert/append action) - re-arm the cap by raising
1887
+ // the floor. Growth already tracked by insert handlers or setIndex growth-on-demand
1888
+ // does NOT hit this branch: by the time refresh() runs again after those, this.nodeset
1889
+ // was already updated by them (they call _evalNodeset() themselves), so it hasn't
1890
+ // grown further within *this* cycle, and _renderTarget is left exactly as they
1891
+ // already set it.
1892
+ // - capped, non-virtual, nodeset did not grow (shrank or same size): never grow the
1893
+ // window here, only clamp it down if it now exceeds a (possibly smaller) total.
1894
+ // - capped, virtual: reclamp BOTH edges to the current total, preserving window size
1895
+ // where possible and sliding the floor back only if the tail shrank out from under
1896
+ // it. Does not auto-follow nodeset growth mid-window - matches the non-virtual
1897
+ // branch's "don't jump the user around" philosophy.
1898
+ if (this._sizeLimit === Infinity) {
1899
+ this._renderTarget = total;
1900
+ this._windowStart = 0;
1901
+ } else if (!this._virtual) {
1902
+ if (total > prevTotal) {
1903
+ this._renderTarget = Math.min(Math.max(this._renderTarget, this._sizeLimit), total);
1904
+ } else {
1905
+ this._renderTarget = Math.min(this._renderTarget, total);
1906
+ }
1907
+ } else {
1908
+ const newStart = Math.max(
1909
+ 0,
1910
+ Math.min(this._windowStart, Math.max(0, total - this._sizeLimit)),
1911
+ );
1912
+ this._windowStart = newStart;
1913
+ this._renderTarget = Math.min(total, newStart + this._sizeLimit);
1914
+ }
1915
+
1916
+ if (this._windowStart !== oldWindowStart) {
1917
+ // Rare: the window floor itself moved (virtual just turned off, or the reclamp above
1918
+ // slid it back because the nodeset's tail shrank out from under the previous window).
1919
+ // The tail-only shrink/grow loops below assume a stable floor and can't bridge a
1920
+ // floor change, so rebuild the window fresh here instead of patching a partial diff.
1921
+ repeatItems.forEach(item => {
1922
+ item.parentNode.removeChild(item);
1923
+ this.getOwnerForm().unRegisterLazyElement(item);
1924
+ });
1925
+ repeatItemCount = 0;
1926
+ }
1927
+
1928
+ const goal = this._renderTarget - this._windowStart;
987
1929
 
988
- if (contextSize < repeatItemCount) {
989
- for (let position = repeatItemCount; position > contextSize; position -= 1) {
1930
+ if (goal < repeatItemCount) {
1931
+ for (let position = repeatItemCount; position > goal; position -= 1) {
990
1932
  const itemToRemove = repeatItems[position - 1];
991
1933
  itemToRemove.parentNode.removeChild(itemToRemove);
992
1934
  this.getOwnerForm().unRegisterLazyElement(itemToRemove);
@@ -997,15 +1939,11 @@ export class FxRepeat extends withDraggability(UIElement, false) {
997
1939
  repeatItems = this.querySelectorAll(':scope > fx-repeatitem');
998
1940
  repeatItemCount = repeatItems.length;
999
1941
 
1000
- if (contextSize > repeatItemCount) {
1001
- for (let position = repeatItemCount + 1; position <= contextSize; position += 1) {
1002
- const newItem = this._createNewRepeatItem();
1003
- this.appendChild(newItem);
1004
-
1005
- this._initVariables(newItem);
1006
-
1007
- newItem.nodeset = this.nodeset[position - 1];
1008
- newItem.index = position;
1942
+ if (goal > repeatItemCount) {
1943
+ for (let position = repeatItemCount + 1; position <= goal; position += 1) {
1944
+ // `position` is 1-based WITHIN the window; _materializeRepeatItem takes a 1-based
1945
+ // LOGICAL position, so offset by _windowStart (always 0 off virtual mode).
1946
+ const newItem = this._materializeRepeatItem(this._windowStart + position);
1009
1947
 
1010
1948
  if (this.getOwnerForm().createNodes) {
1011
1949
  this.getOwnerForm().initData(newItem);
@@ -1023,22 +1961,33 @@ export class FxRepeat extends withDraggability(UIElement, false) {
1023
1961
 
1024
1962
  for (let position = 0; position < repeatItemCount; position += 1) {
1025
1963
  const item = repeatItems[position];
1964
+ const logicalIdx = this._windowStart + position;
1026
1965
  this.getOwnerForm().registerLazyElement(item);
1027
1966
 
1028
- if (item.nodeset !== this.nodeset[position]) {
1029
- item.nodeset = this.nodeset[position];
1967
+ if (item.nodeset !== this.nodeset[logicalIdx]) {
1968
+ item.nodeset = this.nodeset[logicalIdx];
1030
1969
  if (this.getOwnerForm().createNodes) {
1031
1970
  this.getOwnerForm().initData(item);
1032
1971
  }
1033
1972
  }
1973
+ item.index = logicalIdx + 1;
1034
1974
  }
1035
1975
 
1976
+ this._syncSentinels();
1977
+
1036
1978
  const fore = this.getOwnerForm();
1037
1979
  if (!fore.lazyRefresh || force) {
1038
1980
  await Fore.refreshChildren(this, force);
1039
1981
  }
1040
1982
 
1041
- this.setIndex(this.index);
1983
+ // Notify index() dependents only when THIS refresh changed the current item,
1984
+ // i.e. re-evaluating the nodeset put a different node at the current index.
1985
+ // Index changes made elsewhere (repeatitem click/focus, programmatic setIndex)
1986
+ // dispatch their own 'item-changed' — comparing against them here would
1987
+ // re-announce them, firing a spurious duplicate on every repeat refresh that
1988
+ // overlaps a click (e.g. one triggered by the structural-change consumer).
1989
+ const nodeAt = ns => (Array.isArray(ns) ? ns[this.index - 1] : ns);
1990
+ this.setIndex(this.index, nodeAt(prevNodeset) !== nodeAt(this.nodeset));
1042
1991
  }
1043
1992
 
1044
1993
  _initTemplate() {
@@ -1077,16 +2026,28 @@ export class FxRepeat extends withDraggability(UIElement, false) {
1077
2026
  }
1078
2027
 
1079
2028
  _initRepeatItems() {
1080
- this.nodeset.forEach((item, index) => {
1081
- const repeatItem = this._createNewRepeatItem();
1082
- repeatItem.nodeset = this.nodeset[index];
1083
- repeatItem.index = index + 1;
2029
+ this._sizeLimit = this._getSizeLimit();
2030
+ this._virtual = this._isVirtual();
2031
+ this._windowStart = 0; // initial render always starts the window at the beginning
2032
+ const total = this.nodeset.length;
2033
+ this._renderTarget = Math.min(total, this._sizeLimit);
1084
2034
 
1085
- this.appendChild(repeatItem);
2035
+ for (let position = 1; position <= this._renderTarget; position += 1) {
2036
+ const repeatItem = this._materializeRepeatItem(position);
1086
2037
 
1087
2038
  if (this.getOwnerForm().createNodes) {
1088
2039
  this.getOwnerForm().initData(repeatItem);
1089
- if (repeatItem.nodeset.nodeType) {
2040
+
2041
+ // `createdNodeset` is only ever read once, as an insert template for a *new* row
2042
+ // (see fx-insert.js), so it's taken from the LAST MATERIALIZED row - cloning and
2043
+ // clearing every earlier row's nodeset was pure waste (O(n) discarded work, e.g.
2044
+ // 799 of 800 clones for the UNTDID 1001 codelist). It must be snapshotted AFTER
2045
+ // initData() above, which is what actually populates create-nodes-synthesized
2046
+ // descendants (e.g. TaxCategory/ID, TaxCategory/Percent) onto the node - cloning
2047
+ // before that yields an empty shell. Under a size cap this may not be the true
2048
+ // last logical nodeset entry, but it's the best representative shape available
2049
+ // (the true last entry may never be materialized).
2050
+ if (position === this._renderTarget && repeatItem.nodeset.nodeType) {
1090
2051
  const repeatItemClone = repeatItem.nodeset.cloneNode(true);
1091
2052
  this.clearTextValues(repeatItemClone);
1092
2053
  this.createdNodeset = repeatItemClone;
@@ -1097,9 +2058,10 @@ export class FxRepeat extends withDraggability(UIElement, false) {
1097
2058
  this.applyIndex(repeatItem);
1098
2059
  }
1099
2060
 
1100
- Fore.dispatch(this, 'item-created', { nodeset: repeatItem.nodeset, pos: index + 1 });
1101
- this._initVariables(repeatItem);
1102
- });
2061
+ Fore.dispatch(this, 'item-created', { nodeset: repeatItem.nodeset, pos: position });
2062
+ }
2063
+
2064
+ this._syncSentinels();
1103
2065
  }
1104
2066
 
1105
2067
  clearTextValues(node) {