bitboss-ui 3.0.0-beta.12 → 3.0.0-beta.14

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "library": "bitboss-ui",
4
- "version": "3.0.0-beta.12",
4
+ "version": "3.0.0-beta.14",
5
5
  "upgrade": "v2-to-v3",
6
6
  "guide": "ai/guides/migration/v2-to-v3.md",
7
7
  "summary": {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
3
  "library": "bitboss-ui",
4
- "version": "3.0.0-beta.12",
5
- "generatedAt": "2026-08-28T12:47:31.708Z",
4
+ "version": "3.0.0-beta.14",
5
+ "generatedAt": "2026-08-28T14:01:44.030Z",
6
6
  "composables": [
7
7
  {
8
8
  "name": "useBbConfig",
@@ -471,42 +471,17 @@
471
471
  :selected="isSelected(row.item)"
472
472
  :virtual-index="row.virtualIndex"
473
473
  />
474
- <div
474
+ <!-- Behind a component boundary for the same reason the data row
475
+ is: the table re-renders its body on every window move, and this
476
+ row holds the CONSUMER's `#expand` slot — usually a whole nested
477
+ table. See the note at the top of `BbTableExpandRow`. -->
478
+ <BbTableExpandRow
475
479
  v-else
476
- :id="`expanded_${row.item.valueHash}`"
477
- :ref="measureVirtualRow"
478
- :aria-rowindex="
479
- hasProvidedAccessibilityData && row.item.rowIndex
480
- ? row.item.rowIndex + 1
481
- : undefined
482
- "
483
- class="bb-table-expand__row"
484
- :class="{
485
- 'bb-table-expand__row--highlighted': row.item.highlighted,
486
- }"
487
- :data-index="row.virtualIndex"
488
- role="row"
489
- >
490
- <div
491
- :aria-colspan="replacementContentSpan"
492
- class="bb-table-expand__cell"
493
- role="cell"
494
- >
495
- <!-- @vue-ignore -->
496
- <slot
497
- :expand-props="row.item.expandProps"
498
- :expanded="row.item.expanded"
499
- :highlighted="row.item.highlighted"
500
- :item="row.item.item"
501
- name="expand"
502
- :selected="isSelected(row.item)"
503
- :toggle-expanded="row.item.toggleExpanded"
504
- :toggle-highlighted="row.item.toggleHighlighted"
505
- :toggle-selected="row.item.toggleSelected"
506
- :value="row.item.value"
507
- ></slot>
508
- </div>
509
- </div>
480
+ :context="dataRowContext"
481
+ :item="row.item"
482
+ :selected="isSelected(row.item)"
483
+ :virtual-index="row.virtualIndex"
484
+ />
510
485
  </template>
511
486
  </slot>
512
487
 
@@ -605,6 +580,7 @@ import {
605
580
  } from '@/utilities/functions/parsePaginationNumber';
606
581
  import BaseCheckbox from '../BbBaseCheckbox/BbBaseCheckbox.vue';
607
582
  import BbTableDataRow from './BbTableDataRow';
583
+ import BbTableExpandRow from './BbTableExpandRow';
608
584
  import type { DataRowContext } from './BbTableDataRow';
609
585
  import type { Option as BaseOption } from '@/types/Option';
610
586
  import type { Classes } from '@/types/Classes';
@@ -881,6 +857,18 @@ const orderedColumns = computed(() => {
881
857
  : ordered;
882
858
  });
883
859
 
860
+ /**
861
+ * The RENDERED column keys, in order — as distinct from `declaredKeys`, which
862
+ * is the same list in the order the consumer wrote it. Six places derived this
863
+ * independently (the roving header index, the two header-focus helpers, the
864
+ * resize handle's neighbour lookup, the drag's placement math and
865
+ * `columnOrderKey`); they now share one cached derivation and, more to the
866
+ * point, one name for the thing.
867
+ */
868
+ const orderedColumnKeys = computed(() =>
869
+ orderedColumns.value.map((column) => String(column.key))
870
+ );
871
+
884
872
  /**
885
873
  * The next model for "move `key` to render index `toIndex`", or `null` when
886
874
  * that changes nothing (the boundary key, a no-op drop): callers then emit
@@ -928,7 +916,7 @@ const announceColumnMove = (key: string, from: number) => {
928
916
  */
929
917
  const activeHeaderKey = ref<string | null>(null);
930
918
  const rovingHeaderKey = computed(() => {
931
- const keys = orderedColumns.value.map((column) => String(column.key));
919
+ const keys = orderedColumnKeys.value;
932
920
  const active = activeHeaderKey.value;
933
921
  return active !== null && keys.includes(active) ? active : (keys[0] ?? null);
934
922
  });
@@ -964,7 +952,7 @@ const headerCellFor = (key: string) =>
964
952
  ) ?? null;
965
953
 
966
954
  const focusHeaderAt = (index: number) => {
967
- const keys = orderedColumns.value.map((column) => String(column.key));
955
+ const keys = orderedColumnKeys.value;
968
956
  const key = keys[Math.max(0, Math.min(keys.length - 1, index))];
969
957
  if (!key) return;
970
958
  activeHeaderKey.value = key;
@@ -1012,7 +1000,7 @@ const onHeaderKeydown = async (event: KeyboardEvent, key: string) => {
1012
1000
  return;
1013
1001
  }
1014
1002
  if (event.ctrlKey || event.metaKey) return;
1015
- const rendered = orderedColumns.value.map((column) => String(column.key));
1003
+ const rendered = orderedColumnKeys.value;
1016
1004
  const from = rendered.indexOf(key);
1017
1005
  if (from < 0) return;
1018
1006
  let to: number;
@@ -1162,39 +1150,53 @@ const resizedKeys = computed(
1162
1150
  );
1163
1151
 
1164
1152
  /**
1165
- * Cells that clip with an ellipsis instead of spilling: the user-resized
1166
- * columns, and under `virtual` every column sized from the first batch
1167
- * (`virtualWidths`): its floor came from the rows that happened to be there,
1168
- * so a later, longer value must not overflow the cell. A declared `width`
1169
- * keeps wrapping; that is the way to get prose in a virtual table.
1170
- */
1171
- const clippedCellKeys = computed(() => {
1172
- const keys = new Set(resizedKeys.value);
1173
- if (virtualActive.value) {
1174
- for (const column of orderedColumns.value) {
1175
- const key = String(column.key);
1176
- if (column.width == null && key in virtualWidths.value) keys.add(key);
1177
- }
1178
- }
1179
- return keys;
1180
- });
1181
-
1182
- /**
1183
- * Cells that are single-line from the FIRST render of a `virtual` table:
1184
- * every undeclared column, frozen yet or not. The first batch is what the
1185
- * freeze measures, and a cell that may still wrap ("INV-10213" breaks at
1186
- * the hyphen, a phone at its spaces) floors its `auto` track at the wrapped
1187
- * width — the clip that follows then cuts the same value by a character.
1188
- * `nowrap` alone keeps the cell flooring the track (an `overflow: hidden`
1189
- * would zero its automatic minimum); the clip is layered on once frozen.
1153
+ * The width-driven state classes a column's DATA CELLS carry, as one ready
1154
+ * string per column key. Columns in their default state are absent from the
1155
+ * map rather than mapped to `''`.
1156
+ *
1157
+ * This replaces three Sets the row used to interrogate separately, once per
1158
+ * CELL: three membership tests and a three-key object literal on the order of
1159
+ * 250 times per body render, to answer a question that is per COLUMN and moves
1160
+ * only when a resize commits, the first-batch freeze lands, or the columns are
1161
+ * reordered.
1162
+ *
1163
+ * Holding the rules together is the other half of it, because they overlap by
1164
+ * construction and reading any one of them told you only a third of what a
1165
+ * cell would do:
1166
+ *
1167
+ * - `--resized` is a user override. A squeezed track cannot wrap a single
1168
+ * word, so such a cell clips with an ellipsis rather than spilling into its
1169
+ * neighbour. Only those: every other cell keeps wrapping and keeps flooring
1170
+ * its `auto` track exactly as before.
1171
+ * - `--nowrap` is every undeclared column of a `virtual` table, from its FIRST
1172
+ * render, frozen yet or not. The first batch is what the freeze measures,
1173
+ * and a cell that may still wrap ("INV-10213" breaks at the hyphen, a phone
1174
+ * at its spaces) floors its `auto` track at the WRAPPED width — the clip
1175
+ * that follows then cuts the same value by a character. `nowrap` alone keeps
1176
+ * the cell flooring the track; an `overflow: hidden` would zero its
1177
+ * automatic minimum.
1178
+ * - `--clip` is layered onto such a column once it IS frozen: its floor came
1179
+ * from whichever rows happened to be there, so a later, longer value must
1180
+ * not overflow the cell. A declared `width` keeps wrapping — that is how a
1181
+ * virtual table gets prose.
1190
1182
  */
1191
- const nowrapCellKeys = computed(() => {
1192
- const keys = new Set<string>();
1193
- if (!virtualActive.value) return keys;
1183
+ const cellStateClasses = computed(() => {
1184
+ const byKey = new Map<string, string>();
1185
+ const resized = resizedKeys.value;
1186
+ const virtual = virtualActive.value;
1187
+ const frozen = virtualWidths.value;
1194
1188
  for (const column of orderedColumns.value) {
1195
- if (column.width == null) keys.add(String(column.key));
1189
+ const key = String(column.key);
1190
+ const undeclared = virtual && column.width == null;
1191
+ const state: string[] = [];
1192
+ if (undeclared) state.push('bb-table-data__cell--nowrap');
1193
+ if (resized.has(key)) state.push('bb-table-data__cell--resized');
1194
+ if (resized.has(key) || (undeclared && key in frozen)) {
1195
+ state.push('bb-table-data__cell--clip');
1196
+ }
1197
+ if (state.length) byKey.set(key, state.join(' '));
1196
1198
  }
1197
- return keys;
1199
+ return byKey;
1198
1200
  });
1199
1201
 
1200
1202
  /** True when any user override is live (feeds the `bb-table--fixed` hook). */
@@ -1372,7 +1374,7 @@ const onResizerPointerDown = (event: PointerEvent, key: string) => {
1372
1374
  // see above
1373
1375
  }
1374
1376
  document.documentElement.classList.add(RESIZING_ROOT_CLASS);
1375
- const rendered = orderedColumns.value.map((column) => String(column.key));
1377
+ const rendered = orderedColumnKeys.value;
1376
1378
  const nextKey = rendered[rendered.indexOf(key) + 1];
1377
1379
  const nextCell = nextKey ? headerCellFor(nextKey) : null;
1378
1380
  resize = {
@@ -1559,7 +1561,7 @@ const placeDraggedColumn = (rawX: number) => {
1559
1561
  ...cells.filter((c) => !isStuck(c)),
1560
1562
  ];
1561
1563
  const others = ordered.filter((cell) => cell.dataset.columnKey !== drag!.key);
1562
- const rendered = orderedColumns.value.map((column) => String(column.key));
1564
+ const rendered = orderedColumnKeys.value;
1563
1565
  const from = rendered.indexOf(drag.key);
1564
1566
  if (from < 0) return;
1565
1567
 
@@ -2954,9 +2956,7 @@ const trackKeys = computed(() =>
2954
2956
  * `columnTracks`' keys are positions, so they read `0 1 2 3` before and after a
2955
2957
  * reorder; this changes whenever the columns move.
2956
2958
  */
2957
- const columnOrderKey = computed(() =>
2958
- orderedColumns.value.map((column) => String(column.key)).join(' ')
2959
- );
2959
+ const columnOrderKey = computed(() => orderedColumnKeys.value.join(' '));
2960
2960
 
2961
2961
  watch(
2962
2962
  [trackKeys, columnOrderKey],
@@ -3685,25 +3685,39 @@ const flatRows = computed<FlatRow[]>(() => {
3685
3685
 
3686
3686
  /**
3687
3687
  * Wraps a flat row for rendering, with the projection behind a getter (see
3688
- * `BodyRow.item`). Deliberately NOT memoized on the entry: these wrappers are
3689
- * rebuilt on every window move, and a per-entry cache would hand a row a fresh
3690
- * item each time. `mappedItemAt` owns the memo, and it outlives the wrappers.
3688
+ * `BodyRow.item`).
3689
+ *
3690
+ * The getter resolves ONCE per entry. `mappedItemAt` still owns the memo the
3691
+ * identity a row's slots depend on comes from there, not from here, and the
3692
+ * entries are rebuilt on every window move — but the body reads this getter
3693
+ * three times for every rendered row (`:item`, `isSelected(row.item)` and
3694
+ * `isSelectToggleBlocked(row.item)`), so without the local hold each of those
3695
+ * paid a computed read and a `Map` lookup for a value it had already resolved
3696
+ * a line earlier. Counted on the playground during a drag: 107 resolutions per
3697
+ * scroll step for ~33 rendered rows.
3698
+ *
3699
+ * It cannot go stale. Everything that invalidates the memo also invalidates
3700
+ * `bodyRows` — which reads `mappedMemo` for exactly that reason — and a new
3701
+ * `bodyRows` is a new set of these wrappers.
3691
3702
  */
3692
3703
  const bodyEntry = (
3693
3704
  row: FlatRow,
3694
3705
  kind: BodyRow['kind'],
3695
3706
  key: string,
3696
3707
  height: number
3697
- ): BodyRow => ({
3698
- kind,
3699
- index: row.index,
3700
- key,
3701
- virtualIndex: row.virtualIndex,
3702
- height,
3703
- get item() {
3704
- return mappedItemAt(row);
3705
- },
3706
- });
3708
+ ): BodyRow => {
3709
+ let item: MappedItem | undefined;
3710
+ return {
3711
+ kind,
3712
+ index: row.index,
3713
+ key,
3714
+ virtualIndex: row.virtualIndex,
3715
+ height,
3716
+ get item() {
3717
+ return (item ??= mappedItemAt(row));
3718
+ },
3719
+ };
3720
+ };
3707
3721
 
3708
3722
  /**
3709
3723
  * The vertical scroller: the container once its height is bounded (it is
@@ -3800,8 +3814,10 @@ const onBodyFocusOut = (event: FocusEvent) => {
3800
3814
  };
3801
3815
 
3802
3816
  /**
3803
- * What the body reads: the window and the total size, published by
3804
- * `flushVirtual` — NOT the wrapper's reactive handle. A scroll-driven change
3817
+ * The list's total height, published by `flushVirtual` NOT read off the
3818
+ * wrapper's reactive handle. (The window itself is `virtualSlots`, below; a
3819
+ * `virtualItems` ref used to be published alongside this one and nothing ever
3820
+ * read it.) A scroll-driven change
3805
3821
  * (`sync`) is published at once; a measurement-driven one (a mounted row
3806
3822
  * measured taller or shorter than its estimate, which is the browser's
3807
3823
  * ResizeObserver callback) is published on the next frame. The offsets and
@@ -3811,7 +3827,6 @@ const onBodyFocusOut = (event: FocusEvent) => {
3811
3827
  * same delivery loop, which the browser reports as "ResizeObserver loop
3812
3828
  * completed with undelivered notifications".
3813
3829
  */
3814
- const virtualItems = shallowRef<VirtualItem[]>([]);
3815
3830
  const virtualTotal = ref(0);
3816
3831
  let virtualFlushFrame = 0;
3817
3832
 
@@ -3925,6 +3940,11 @@ const noteVirtualScroll = (offset: number) => {
3925
3940
  const virtualRowMean = ref(0);
3926
3941
  /** Entry count of the last cache we summed — see `syncVirtualMean`. */
3927
3942
  let virtualMeanCacheSize = -1;
3943
+ /**
3944
+ * Whether the estimate has settled. See the latch in `syncVirtualMean`: it is
3945
+ * released only by a new row list, where the reset below clears it.
3946
+ */
3947
+ let virtualEstimateLatched = false;
3928
3948
  const syncVirtualMean = () => {
3929
3949
  const cache = (
3930
3950
  virtualizerHandle as { itemSizeCache?: Map<string, number> } | null
@@ -3959,7 +3979,29 @@ const syncVirtualMean = () => {
3959
3979
  mode = size;
3960
3980
  }
3961
3981
  }
3962
- if (mode > 0) virtualRowMean.value = mode;
3982
+ // LATCHED once enough rows have been measured to choose well, and then
3983
+ // left alone until the row list itself changes.
3984
+ //
3985
+ // The estimate stands in for every row NOT yet measured, so moving it
3986
+ // resizes the scrollport by (rows still unmeasured × the delta). Measuring
3987
+ // a row does not do that — the virtualizer swaps that one row's estimate
3988
+ // for its real height and adjusts the total by the difference, which is
3989
+ // smooth and invisible. Only re-estimating the whole tail at once can jump,
3990
+ // and on a list whose rows fall into two clusters (most short, some
3991
+ // wrapping) a freely-moving mode does exactly that: the two tallies trade
3992
+ // places as measurement proceeds, and each swap moved the scrollport
3993
+ // between 161,000px and 671,000px — the thumb skipping backwards while the
3994
+ // reader scrolled forwards. Hysteresis was not enough; a 50/50 split
3995
+ // crosses any margin repeatedly.
3996
+ //
3997
+ // Twenty rows is about one full window, so the sample is a real one rather
3998
+ // than the first five rows of a sorted list. Being wrong afterwards costs
3999
+ // only correction passes nobody sees; being unstable costs the reader
4000
+ // their place, which is the one thing a scrollbar exists to keep.
4001
+ if (mode > 0 && !virtualEstimateLatched) {
4002
+ virtualRowMean.value = mode;
4003
+ if (count >= 20) virtualEstimateLatched = true;
4004
+ }
3963
4005
  }
3964
4006
  // The spacers tile their skeleton on this cadence.
3965
4007
  syncPlaceholderRowHeight();
@@ -4009,7 +4051,6 @@ const flushVirtual = (scrolled = false) => {
4009
4051
  }
4010
4052
  if (!virtualizerHandle) return;
4011
4053
  const items = virtualActive.value ? virtualizerHandle.getVirtualItems() : [];
4012
- virtualItems.value = items;
4013
4054
  virtualTotal.value = virtualizerHandle.getTotalSize();
4014
4055
  const slots: VirtualSlot[] = [];
4015
4056
  let span: { start: number; end: number } | null = null;
@@ -4027,14 +4068,19 @@ const flushVirtual = (scrolled = false) => {
4027
4068
  ).range;
4028
4069
  const first = range ? range.startIndex : items[0].index;
4029
4070
  const last = range ? range.endIndex : items[items.length - 1].index;
4071
+ // The item IS its measurement — `getVirtualItems` pushes the entries of
4072
+ // `measurementsCache` themselves, it does not copy them — so looking the
4073
+ // same index up again bought a second read and a `continue` branch that
4074
+ // could not be taken. Still copied into our own shape rather than
4075
+ // forwarded: a slot is what the body renders against and what the next
4076
+ // flush compares itself to, and it should not be an object the
4077
+ // virtualizer owns.
4030
4078
  for (const item of items) {
4031
- const measurement = measurements?.[item.index];
4032
- if (!measurement) continue;
4033
4079
  slots.push({
4034
4080
  index: item.index,
4035
- start: measurement.start,
4036
- end: measurement.end,
4037
- size: measurement.size,
4081
+ start: item.start,
4082
+ end: item.end,
4083
+ size: item.size,
4038
4084
  });
4039
4085
  }
4040
4086
  // The scroll range the WINDOW covers — the visible range plus its
@@ -4083,30 +4129,61 @@ const scheduleVirtualFlush = () => {
4083
4129
  });
4084
4130
  };
4085
4131
 
4132
+ /**
4133
+ * The three virtualizer callbacks that must keep ONE IDENTITY for the table's
4134
+ * life. They read their inputs when they are called, so a closure per options
4135
+ * evaluation bought nothing — and cost a great deal.
4136
+ *
4137
+ * `getItemKey` is the memo key of tanstack's `getMeasurementOptions`
4138
+ * (`virtual-core`: the deps are `[count, paddingStart, scrollMargin,
4139
+ * getItemKey, enabled, …]`, compared by IDENTITY). A new function there clears
4140
+ * `pendingMeasuredCacheIndexes` and invalidates `getMeasurements`, which then
4141
+ * rebuilds EVERY measurement from index 0 — one object, one `getItemKey` and
4142
+ * one `estimateSize` call per row in the whole list, not per row on screen.
4143
+ *
4144
+ * Built inside the options computed, they were re-created whenever anything
4145
+ * that computed reads changed — and one of those is `pinnedVirtualIndex`,
4146
+ * which is written on every `focusin`/`focusout` in the body. So tabbing
4147
+ * between two buttons in two rows, or arrowing down the keyboard grammar, paid
4148
+ * a full-list measurement rebuild: measured on the playground at 50,000 rows,
4149
+ * 94,076 `getItemKey` calls across twenty focus moves. Hoisted, focus moves
4150
+ * the range and nothing else.
4151
+ *
4152
+ * `rangeExtractor` deliberately STAYS in the computed: it is the memo key of
4153
+ * `getVirtualIndexes`, so the pinned row would never be added to the range if
4154
+ * its identity did not move with `pinnedVirtualIndex`. That memo is over one
4155
+ * window, not over the list.
4156
+ */
4157
+ const virtualOnChange = (
4158
+ instance: { getScrollOffset: () => number },
4159
+ sync: boolean
4160
+ ) => {
4161
+ if (sync) {
4162
+ noteVirtualScroll(instance.getScrollOffset());
4163
+ flushVirtual(true);
4164
+ } else {
4165
+ scheduleVirtualFlush();
4166
+ }
4167
+ };
4168
+
4169
+ const virtualItemKey = (index: number) => flatRows.value[index]?.key ?? index;
4170
+
4171
+ const virtualEstimateSize = (index: number) =>
4172
+ (flatRows.value[index]?.kind === 'expand' ? 3 : 1) *
4173
+ (virtualRowMean.value || virtualRowEstimate.value);
4174
+
4086
4175
  const rowVirtualizer = useVirtualizer(
4087
4176
  computed(() => {
4088
4177
  const scroller = virtualScroller.value;
4089
4178
  const isWindow = scroller === window;
4090
4179
  const pinned = pinnedVirtualIndex.value;
4091
4180
  return {
4092
- onChange: (
4093
- instance: { getScrollOffset: () => number },
4094
- sync: boolean
4095
- ) => {
4096
- if (sync) {
4097
- noteVirtualScroll(instance.getScrollOffset());
4098
- flushVirtual(true);
4099
- } else {
4100
- scheduleVirtualFlush();
4101
- }
4102
- },
4181
+ onChange: virtualOnChange,
4103
4182
  count: virtualActive.value ? flatRows.value.length : 0,
4104
4183
  enabled: virtualActive.value,
4105
4184
  getScrollElement: () => scroller as any,
4106
- estimateSize: (index: number) =>
4107
- (flatRows.value[index]?.kind === 'expand' ? 3 : 1) *
4108
- (virtualRowMean.value || virtualRowEstimate.value),
4109
- getItemKey: (index: number) => flatRows.value[index]?.key ?? index,
4185
+ estimateSize: virtualEstimateSize,
4186
+ getItemKey: virtualItemKey,
4110
4187
  overscan: VIRTUAL_OVERSCAN,
4111
4188
  scrollMargin: virtualScrollMargin.value,
4112
4189
  // Before the scroller is known (first render, SSR): a window's worth
@@ -4185,7 +4262,6 @@ const bodyRows = computed<BodyRow[]>(() => {
4185
4262
  const out: BodyRow[] = [];
4186
4263
  const margin = virtualScrollMargin.value;
4187
4264
  const fast = virtualFast.value;
4188
- const real = new Set<string>();
4189
4265
  let cursor = margin;
4190
4266
  let last: FlatRow | undefined;
4191
4267
  for (const slot of slots) {
@@ -4201,13 +4277,10 @@ const bodyRows = computed<BodyRow[]>(() => {
4201
4277
  out.push(bodyEntry(row, 'placeholder', `p:${row.key}`, slot.size));
4202
4278
  } else {
4203
4279
  out.push(bodyEntry(row, row.kind, row.key, 0));
4204
- real.add(row.key);
4205
4280
  }
4206
4281
  cursor = slot.end;
4207
4282
  last = row;
4208
4283
  }
4209
- // Bookkeeping, not state: remember what has been rendered real.
4210
- for (const key of real) virtualRealKeys.add(key);
4211
4284
  const tail = virtualTotal.value - (cursor - margin);
4212
4285
  if (tail > 0.5 && last) {
4213
4286
  out.push(bodyEntry(last, 'spacer', 's:end', tail));
@@ -4215,6 +4288,39 @@ const bodyRows = computed<BodyRow[]>(() => {
4215
4288
  return out;
4216
4289
  });
4217
4290
 
4291
+ /**
4292
+ * "Rows you have already seen stay real" — recorded AFTER the patch, not
4293
+ * inside `bodyRows`.
4294
+ *
4295
+ * It used to be a `virtualRealKeys.add` at the end of that computed, and a
4296
+ * computed that writes to the world outside it is one whose result depends on
4297
+ * how many times it has run. Vue re-evaluates a computed whenever a dependency
4298
+ * moves and somebody asks for the value, which is NOT the same event as "the
4299
+ * body rendered this": an evaluation whose output never reached the DOM still
4300
+ * promoted its rows out of the skeleton, and the next fast scroll then mounted
4301
+ * a full row — sixteen cells and the consumer's slots — for a reader who is
4302
+ * nowhere near it. Post-flush the set says exactly what it claims: the keys of
4303
+ * rows that are, or have been, on screen.
4304
+ *
4305
+ * Both real kinds are recorded, as they were before. Only `row` is ever
4306
+ * consulted (an expand row is never drawn as a placeholder), but keeping
4307
+ * `expand` in makes the set answer the question its name asks.
4308
+ *
4309
+ * This watcher forces no extra work: the pin pass below already subscribes to
4310
+ * `bodyRows` post-flush, so the value is computed for that tick regardless.
4311
+ */
4312
+ watch(
4313
+ bodyRows,
4314
+ (rows) => {
4315
+ if (!virtualActive.value) return;
4316
+ for (const row of rows) {
4317
+ if (row.kind === 'row' || row.kind === 'expand')
4318
+ virtualRealKeys.add(row.key);
4319
+ }
4320
+ },
4321
+ { flush: 'post' }
4322
+ );
4323
+
4218
4324
  /**
4219
4325
  * Column widths of a `virtual` table, measured once from the first rendered
4220
4326
  * batch (key → px). A windowed body sizes its `auto` tracks from the rows it
@@ -4347,6 +4453,7 @@ watch(
4347
4453
  // re-summed even if the size cache happens to hold the same count.
4348
4454
  virtualRealKeys = new Set();
4349
4455
  virtualMeanCacheSize = -1;
4456
+ virtualEstimateLatched = false;
4350
4457
  // New rows (a refetch, a filter) render on this tick, not the next frame.
4351
4458
  flushVirtual();
4352
4459
  });
@@ -4645,11 +4752,10 @@ const dataRowContext = computed<DataRowContext>(() => ({
4645
4752
  keyboardNavigation: !!props.keyboardNavigation,
4646
4753
  contentInert: contentInert.value,
4647
4754
  hasRowIndex: hasProvidedAccessibilityData.value,
4755
+ columnSpan: replacementContentSpan.value,
4648
4756
  inputName: randomName,
4649
4757
  tdClass: props.tdClass,
4650
- resizedKeys: resizedKeys.value,
4651
- clippedKeys: clippedCellKeys.value,
4652
- nowrapKeys: nowrapCellKeys.value,
4758
+ cellClasses: cellStateClasses.value,
4653
4759
  sortOrders: sortOrders.value,
4654
4760
  // A plain table has no virtualizer: the ref is still passed, and is a no-op.
4655
4761
  measureRow: measureVirtualRow,