@rickcedwhat/playwright-smart-table 6.18.0 → 6.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/smartRow.js CHANGED
@@ -1,5 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ const rowResolution_1 = require("./engine/rowResolution");
3
4
  const fill_1 = require("./strategies/fill");
4
5
  const stringUtils_1 = require("./utils/stringUtils");
5
6
  const debugUtils_1 = require("./utils/debugUtils");
@@ -146,38 +147,6 @@ const _navigateToCell = async (params) => {
146
147
  if (typeof rowIndex !== 'number') {
147
148
  throw new Error('Row index is required for navigation');
148
149
  }
149
- const navigateOnce = async () => {
150
- // Get current position again to be sure
151
- let currRow = 0;
152
- let currCol = 0;
153
- if (config.strategies.getActiveCell) {
154
- const ac = await config.strategies.getActiveCell({ config, root: rootLocator, page, resolve });
155
- if (ac) {
156
- currRow = ac.rowIndex;
157
- currCol = ac.columnIndex;
158
- }
159
- }
160
- const rDiff = rowIndex - currRow;
161
- const cDiff = index - currCol;
162
- // Move one step vertically
163
- if (rDiff > 0 && nav.goDown) {
164
- (0, debugUtils_1.logDebug)(config, 'verbose', '_navigateToCell: moving down');
165
- await nav.goDown(context);
166
- }
167
- else if (rDiff < 0 && nav.goUp) {
168
- (0, debugUtils_1.logDebug)(config, 'verbose', '_navigateToCell: moving up');
169
- await nav.goUp(context);
170
- }
171
- // Move one step horizontally
172
- if (cDiff > 0 && nav.goRight) {
173
- (0, debugUtils_1.logDebug)(config, 'verbose', '_navigateToCell: moving right');
174
- await nav.goRight(context);
175
- }
176
- else if (cDiff < 0 && nav.goLeft) {
177
- (0, debugUtils_1.logDebug)(config, 'verbose', '_navigateToCell: moving left');
178
- await nav.goLeft(context);
179
- }
180
- };
181
150
  if (await targetReached()) {
182
151
  // Already there. If we have a barrier, check-in to stay in lock-step.
183
152
  if (barrier)
@@ -311,7 +280,7 @@ const _navigateToCell = async (params) => {
311
280
  else {
312
281
  navResult = await navigateUntilReached();
313
282
  }
314
- // navigateUntilReached may return a Locator it found mid-navigation; duck-type check via .count() (public API) rather than the internal _isLocator flag
283
+ // navigateUntilReached may return a Locator it found mid-navigation
315
284
  if (navResult && typeof navResult.count === 'function') {
316
285
  const loc = navResult;
317
286
  if (await loc.count() > 0)
@@ -353,9 +322,13 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
353
322
  smart._barrier = barrier;
354
323
  // Attach Methods
355
324
  smart.getCell = (colName) => {
325
+ var _a, _b;
326
+ if ((_a = config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[colName]) {
327
+ throw new Error(`Column "${colName}" is synthetic (no DOM cell) — use getValue("${colName}") or toJSON() instead`);
328
+ }
356
329
  const idx = map.get(colName);
357
330
  if (idx === undefined) {
358
- throw new Error((0, stringUtils_1.buildColumnNotFoundError)(colName, Array.from(map.keys())));
331
+ throw new Error((0, stringUtils_1.buildColumnNotFoundError)(colName, [...map.keys(), ...Object.keys((_b = config.syntheticColumns) !== null && _b !== void 0 ? _b : {})]));
359
332
  }
360
333
  let baseLocator;
361
334
  if (config.strategies.getCellLocator) {
@@ -413,8 +386,145 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
413
386
  smart.wasFound = () => {
414
387
  return !smart[sentinel_1.SENTINEL_ROW];
415
388
  };
389
+ smart.getValue = async (colName) => {
390
+ var _a, _b, _c;
391
+ const syntheticDef = (_a = config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[colName];
392
+ if (syntheticDef) {
393
+ const guardedRow = Object.create(smart);
394
+ guardedRow.getValue = async (innerCol) => {
395
+ var _a;
396
+ if ((_a = config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[innerCol]) {
397
+ throw new Error(`[SmartTable] Synthetic column "${innerCol}" cannot be read from inside another synthetic column's compute(). ` +
398
+ `Synthetic columns may only reference real or override columns.`);
399
+ }
400
+ return smart.getValue(innerCol);
401
+ };
402
+ return String(await syntheticDef.compute(guardedRow));
403
+ }
404
+ const idx = map.get(colName);
405
+ if (idx === undefined) {
406
+ throw new Error((0, stringUtils_1.buildColumnNotFoundError)(colName, [...map.keys(), ...Object.keys((_b = config.syntheticColumns) !== null && _b !== void 0 ? _b : {})]));
407
+ }
408
+ const columnOverride = (_c = config.columnOverrides) === null || _c === void 0 ? void 0 : _c[colName];
409
+ const cell = config.strategies.getCellLocator
410
+ ? config.strategies.getCellLocator({
411
+ row: rowLocator, root: rootLocator, columnName: colName,
412
+ columnIndex: idx, rowIndex, page: rootLocator.page(), config,
413
+ })
414
+ : resolve(config.cellSelector, rowLocator).nth(idx);
415
+ if (columnOverride === null || columnOverride === void 0 ? void 0 : columnOverride.read) {
416
+ const getCell = (name) => {
417
+ const ci = map.get(name);
418
+ if (ci === undefined)
419
+ throw new Error(`Column "${name}" not found`);
420
+ return resolve(config.cellSelector, rowLocator).nth(ci);
421
+ };
422
+ return String(await columnOverride.read(cell, { row: smart, columnName: colName, columnIndex: idx, getCell }));
423
+ }
424
+ return (await cell.innerText()).trim();
425
+ };
416
426
  smart.toJSON = async (options) => {
417
- var _a, _b, _c, _d, _e;
427
+ var _a, _b, _c, _d, _e, _f, _g, _h;
428
+ // Atomic mode: snapshot the row in a single evaluate, then apply column overrides
429
+ // against a frozen reconstruction. Zero inter-column stagger — even in-place React
430
+ // re-renders can't affect it because overrides read from a detached clone.
431
+ if (options === null || options === void 0 ? void 0 : options.atomic) {
432
+ const cellSel = config.cellSelector;
433
+ if (typeof cellSel !== 'string') {
434
+ throw new Error('[SmartTable] toJSON({ atomic: true }) requires cellSelector to be a CSS string, not a function.');
435
+ }
436
+ const page = rootLocator.page();
437
+ // Step 1: clone row in browser memory (NOT in the DOM), extract text + row HTML.
438
+ // Uses textContent (not innerText) because the clone is detached — innerText
439
+ // falls back to textContent on detached nodes per HTML spec, so be explicit.
440
+ const cloneHandle = await rowLocator.evaluateHandle(el => el.cloneNode(true));
441
+ let snapshot;
442
+ try {
443
+ snapshot = await cloneHandle.evaluate((row, sel) => {
444
+ const cells = row.querySelectorAll(sel);
445
+ return {
446
+ rowHTML: row.outerHTML,
447
+ cells: [...cells].map(c => ({
448
+ text: (c.textContent || '').trim(),
449
+ })),
450
+ };
451
+ }, cellSel);
452
+ }
453
+ finally {
454
+ await cloneHandle.dispose();
455
+ }
456
+ // Determine which columns have overrides (need lazy materialization)
457
+ const columnsToProcess = [...map.entries()]
458
+ .filter(([col]) => !(options === null || options === void 0 ? void 0 : options.columns) || options.columns.includes(col));
459
+ const hasAnyOverride = columnsToProcess.some(([col]) => { var _a, _b; return !!((_b = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[col]) === null || _b === void 0 ? void 0 : _b.read); });
460
+ // Step 2: lazily materialize the row if any override needs a Locator.
461
+ // Uses position:fixed off-screen (NOT display:none — that breaks innerText).
462
+ const snapId = `__st_snap_${Math.random().toString(36).slice(2, 10)}`;
463
+ let materialized = false;
464
+ const materializeRow = async () => {
465
+ if (materialized)
466
+ return;
467
+ await page.evaluate(([html, id]) => {
468
+ const c = document.createElement('div');
469
+ c.id = id;
470
+ c.style.cssText = 'position:fixed;left:-99999px;top:-99999px;opacity:0;pointer-events:none';
471
+ // Wrap in <table><tbody> so the browser's HTML parser keeps <tr>/<td> intact.
472
+ c.innerHTML = `<table><tbody>${html}</tbody></table>`;
473
+ document.body.appendChild(c);
474
+ }, [snapshot.rowHTML, snapId]);
475
+ materialized = true;
476
+ };
477
+ const cleanupSnapshot = async () => {
478
+ if (!materialized)
479
+ return;
480
+ await page.evaluate(id => { var _a; return (_a = document.getElementById(id)) === null || _a === void 0 ? void 0 : _a.remove(); }, snapId);
481
+ };
482
+ // Step 3: build getCell helper — returns Locator to cell in materialized row
483
+ const getCell = (colName) => {
484
+ const colIdx = map.get(colName);
485
+ if (colIdx === undefined)
486
+ throw new Error(`[SmartTable] getCell: column "${colName}" not found`);
487
+ return page.locator(`#${snapId}`).locator(cellSel).nth(colIdx);
488
+ };
489
+ try {
490
+ if (hasAnyOverride)
491
+ await materializeRow();
492
+ const result = {};
493
+ for (const [col, idx] of columnsToProcess) {
494
+ const columnOverride = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[col];
495
+ const mapper = columnOverride === null || columnOverride === void 0 ? void 0 : columnOverride.read;
496
+ if (mapper) {
497
+ const cell = page.locator(`#${snapId}`).locator(cellSel).nth(idx);
498
+ try {
499
+ result[col] = await mapper(cell, { row: smart, columnName: col, columnIndex: idx, getCell });
500
+ }
501
+ catch (err) {
502
+ const wrapped = new Error(`[SmartTable] toJSON({ atomic: true }): column override for "${col}" failed — ${err.message}`);
503
+ wrapped.cause = err;
504
+ throw wrapped;
505
+ }
506
+ }
507
+ else if (idx < snapshot.cells.length) {
508
+ result[col] = snapshot.cells[idx].text;
509
+ }
510
+ }
511
+ for (const [name, def] of Object.entries((_b = config.syntheticColumns) !== null && _b !== void 0 ? _b : {})) {
512
+ if ((options === null || options === void 0 ? void 0 : options.columns) && !options.columns.includes(name))
513
+ continue;
514
+ const snapshotRow = Object.create(smart);
515
+ snapshotRow.getValue = async (col) => {
516
+ if (col in result)
517
+ return String(result[col]);
518
+ return smart.getValue(col);
519
+ };
520
+ result[name] = String(await def.compute(snapshotRow));
521
+ }
522
+ return result;
523
+ }
524
+ finally {
525
+ await cleanupSnapshot();
526
+ }
527
+ }
418
528
  const result = {};
419
529
  const page = rootLocator.page();
420
530
  // Build a getHeaderCell helper for the beforeCellRead context.
@@ -427,17 +537,82 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
427
537
  throw new Error(`Column "${colName}" not found`);
428
538
  return resolve(config.headerSelector, rootLocator).nth(idx);
429
539
  };
540
+ // #366 / #392: on virtualized tables the row's DOM node can recycle between the
541
+ // per-column awaits below. When a self-healing locator is in use (the row locator was
542
+ // rebuilt with a CSS selector, e.g. `[data-rowindex="47"]`), the locator automatically
543
+ // re-queries the DOM — no rescan needed. For plain-number strategies (JS-only, no
544
+ // selector), fall back to the original rescan-then-scrollToRow approach.
545
+ const resolveRI = config.strategies.resolveRowIndex;
546
+ const canPin = !!resolveRI && typeof rowIndex === 'number';
547
+ const isSelfHealing = smart._selfHealing === true;
548
+ const stableCtx = { root: rootLocator, config, page, resolve };
549
+ const inBatch = smart._inBatch === true || !!smart._barrier;
550
+ let lastGood = rowLocator;
551
+ const resolveStableRow = async () => {
552
+ var _a;
553
+ // Self-healing locator: always re-queries the DOM by attribute — no rescan needed.
554
+ if (isSelfHealing)
555
+ return rowLocator;
556
+ if (!canPin)
557
+ return rowLocator;
558
+ const result = await resolveRI(lastGood);
559
+ const resolvedIndex = result !== undefined ? (0, rowResolution_1.normalizeRowIndexResult)(result).index : undefined;
560
+ if (resolvedIndex === rowIndex)
561
+ return lastGood; // still correct
562
+ // Drifted (recycled). Re-locate the row carrying the expected logical index.
563
+ const rescan = async () => {
564
+ const rows = await resolve(config.rowSelector, rootLocator).all();
565
+ for (const r of rows) {
566
+ const rResult = await resolveRI(r);
567
+ if (rResult !== undefined && (0, rowResolution_1.normalizeRowIndexResult)(rResult).index === rowIndex)
568
+ return r;
569
+ }
570
+ return null;
571
+ };
572
+ let recovered = await rescan();
573
+ if (!recovered && !inBatch && ((_a = config.strategies.viewport) === null || _a === void 0 ? void 0 : _a.scrollToRow)) {
574
+ await config.strategies.viewport.scrollToRow(stableCtx, rowIndex);
575
+ recovered = await rescan();
576
+ }
577
+ if (!recovered) {
578
+ throw new Error(`[SmartTable] toJSON: row ${rowIndex} recycled out of the DOM mid-read and could not be recovered — ` +
579
+ `the returned object would mix fields from different rows. ` +
580
+ (inBatch
581
+ ? `(During a map/forEach batch, scroll-back recovery is disabled to avoid disrupting sibling rows; the row must stay mounted for the read.)`
582
+ : `Ensure a viewport.scrollToRow can restore the row.`));
583
+ }
584
+ lastGood = recovered;
585
+ return recovered;
586
+ };
587
+ // getCell for non-atomic mode: returns a live cell Locator via the column map.
588
+ // Uses the last-known-good row from the re-pin so it's as fresh as possible,
589
+ // but returns raw cell Locators, not override-processed values.
590
+ const getCellLive = (colName) => {
591
+ const colIdx = map.get(colName);
592
+ if (colIdx === undefined)
593
+ throw new Error(`[SmartTable] getCell: column "${colName}" not found`);
594
+ const row = lastGood;
595
+ if (config.strategies.getCellLocator) {
596
+ return config.strategies.getCellLocator({
597
+ row, root: rootLocator, columnName: colName,
598
+ columnIndex: colIdx, rowIndex, page, config,
599
+ });
600
+ }
601
+ return resolve(config.cellSelector, row).nth(colIdx);
602
+ };
430
603
  for (const [col, idx] of map.entries()) {
431
604
  if ((options === null || options === void 0 ? void 0 : options.columns) && !options.columns.includes(col)) {
432
605
  continue;
433
606
  }
607
+ // Re-pin to the correct logical row before reading this column (#366).
608
+ const stableRow = await resolveStableRow();
434
609
  // Check if we have a column override for this column
435
- const columnOverride = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[col];
610
+ const columnOverride = (_c = config.columnOverrides) === null || _c === void 0 ? void 0 : _c[col];
436
611
  const mapper = columnOverride === null || columnOverride === void 0 ? void 0 : columnOverride.read;
437
612
  // --- Navigation Logic Start ---
438
613
  const cell = config.strategies.getCellLocator
439
614
  ? config.strategies.getCellLocator({
440
- row: rowLocator,
615
+ row: stableRow,
441
616
  root: rootLocator,
442
617
  columnName: col,
443
618
  columnIndex: idx,
@@ -445,7 +620,7 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
445
620
  page: page,
446
621
  config
447
622
  })
448
- : resolve(config.cellSelector, rowLocator).nth(idx);
623
+ : resolve(config.cellSelector, stableRow).nth(idx);
449
624
  let targetCell = cell;
450
625
  // Always call _navigateToCell to ensure Lock-Step synchronization
451
626
  // even if the cell is already present (it handles check-in inside)
@@ -457,7 +632,7 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
457
632
  getHeaders: table === null || table === void 0 ? void 0 : table.getHeaders,
458
633
  column: col,
459
634
  index: idx,
460
- rowLocator,
635
+ rowLocator: stableRow,
461
636
  rowIndex,
462
637
  barrier: smart._barrier
463
638
  });
@@ -472,20 +647,20 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
472
647
  cell: targetCell,
473
648
  columnName: col,
474
649
  columnIndex: idx,
475
- row: rowLocator,
650
+ row: stableRow,
476
651
  page,
477
652
  root: rootLocator,
478
653
  getHeaderCell,
479
654
  });
480
655
  }
481
656
  // Cell loading wait (if configured)
482
- const isCellLoading = (_b = config.strategies.loading) === null || _b === void 0 ? void 0 : _b.isCellLoading;
483
- const rawCellTimeout = (_c = config.strategies.loading) === null || _c === void 0 ? void 0 : _c.cellLoadingTimeout;
657
+ const isCellLoading = (_d = config.strategies.loading) === null || _d === void 0 ? void 0 : _d.isCellLoading;
658
+ const rawCellTimeout = (_e = config.strategies.loading) === null || _e === void 0 ? void 0 : _e.cellLoadingTimeout;
484
659
  // undefined = unset (read as-is immediately); 0 = no wait (immediate check); >0 = wait up to N ms
485
660
  const cellLoadingTimeout = rawCellTimeout !== undefined && Number.isFinite(rawCellTimeout) && rawCellTimeout >= 0
486
661
  ? rawCellTimeout
487
662
  : undefined;
488
- const onCellLoadingTimeout = (_e = (_d = config.strategies.loading) === null || _d === void 0 ? void 0 : _d.onCellLoadingTimeout) !== null && _e !== void 0 ? _e : 'read-as-is';
663
+ const onCellLoadingTimeout = (_g = (_f = config.strategies.loading) === null || _f === void 0 ? void 0 : _f.onCellLoadingTimeout) !== null && _g !== void 0 ? _g : 'read-as-is';
489
664
  if (isCellLoading && await isCellLoading(targetCell, col, smart)) {
490
665
  if (cellLoadingTimeout !== undefined) {
491
666
  (0, debugUtils_1.logDebug)(config, 'verbose', `toJSON: cell "${col}" — waiting up to ${cellLoadingTimeout}ms`);
@@ -515,7 +690,7 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
515
690
  }
516
691
  if (mapper) {
517
692
  // Apply mapper
518
- const mappedValue = await mapper(targetCell);
693
+ const mappedValue = await mapper(targetCell, { row: smart, columnName: col, columnIndex: idx, getCell: getCellLive });
519
694
  result[col] = mappedValue;
520
695
  }
521
696
  else {
@@ -524,17 +699,31 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
524
699
  result[col] = (text || '').trim();
525
700
  }
526
701
  }
702
+ for (const [name, def] of Object.entries((_h = config.syntheticColumns) !== null && _h !== void 0 ? _h : {})) {
703
+ if ((options === null || options === void 0 ? void 0 : options.columns) && !options.columns.includes(name))
704
+ continue;
705
+ const snapshotRow = Object.create(smart);
706
+ snapshotRow.getValue = async (col) => {
707
+ if (col in result)
708
+ return String(result[col]);
709
+ return smart.getValue(col);
710
+ };
711
+ result[name] = String(await def.compute(snapshotRow));
712
+ }
527
713
  return result;
528
714
  };
529
715
  smart.smartFill = async (data, fillOptions) => {
530
- var _a;
716
+ var _a, _b, _c;
531
717
  (0, debugUtils_1.logDebug)(config, 'info', 'Filling row', data);
532
718
  for (const [colName, value] of Object.entries(data)) {
533
719
  if (value === undefined)
534
720
  continue;
721
+ if ((_a = config.syntheticColumns) === null || _a === void 0 ? void 0 : _a[colName]) {
722
+ throw new Error(`Cannot fill synthetic column "${colName}" — it has no DOM cell`);
723
+ }
535
724
  const colIdx = map.get(colName);
536
725
  if (colIdx === undefined) {
537
- throw new Error((0, stringUtils_1.buildColumnNotFoundError)(colName, Array.from(map.keys())));
726
+ throw new Error((0, stringUtils_1.buildColumnNotFoundError)(colName, [...map.keys(), ...Object.keys((_b = config.syntheticColumns) !== null && _b !== void 0 ? _b : {})]));
538
727
  }
539
728
  await _navigateToCell({
540
729
  config,
@@ -548,12 +737,15 @@ const createSmartRow = (rowLocator, map, rowIndex, config, rootLocator, resolve,
548
737
  rowIndex,
549
738
  barrier: smart._barrier
550
739
  });
551
- const columnOverride = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[colName];
740
+ const columnOverride = (_c = config.columnOverrides) === null || _c === void 0 ? void 0 : _c[colName];
552
741
  if (columnOverride === null || columnOverride === void 0 ? void 0 : columnOverride.write) {
553
742
  const cellLocator = smart.getCell(colName);
554
743
  let currentValue;
555
744
  if (columnOverride.read) {
556
- currentValue = await columnOverride.read(cellLocator);
745
+ currentValue = await columnOverride.read(cellLocator, {
746
+ row: smart, columnName: colName, columnIndex: colIdx,
747
+ getCell: (name) => smart.getCell(name),
748
+ });
557
749
  }
558
750
  await columnOverride.write({
559
751
  cell: cellLocator,
@@ -14,7 +14,11 @@ exports.FillStrategies = {
14
14
  let currentValue;
15
15
  // Auto-sync: If read exists, get current state first
16
16
  if (columnOverride.read) {
17
- currentValue = await columnOverride.read(cell);
17
+ const columnIndex = table ? (await table.getHeaders()).indexOf(columnName) : -1;
18
+ currentValue = await columnOverride.read(cell, {
19
+ row, columnName, columnIndex,
20
+ getCell: (name) => row.getCell(name),
21
+ });
18
22
  }
19
23
  await columnOverride.write({
20
24
  cell,
@@ -69,7 +73,6 @@ exports.FillStrategies = {
69
73
  const inputTag = await inputLocator.evaluate((el) => el.tagName.toLowerCase()).catch(() => 'unknown');
70
74
  const inputType = await inputLocator.getAttribute('type').catch(() => null);
71
75
  const isContentEditable = await inputLocator.getAttribute('contenteditable').catch(() => null);
72
- // console.log(`[SmartTable] Filling "${columnName}" with value "${value}" (input: ${inputTag}, type: ${inputType})`);
73
76
  if (inputType === 'checkbox' || inputType === 'radio') {
74
77
  // Boolean value for checkbox/radio
75
78
  const shouldBeChecked = Boolean(value);
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.HeaderStrategies = void 0;
4
+ const debugUtils_1 = require("../utils/debugUtils");
4
5
  exports.HeaderStrategies = {
5
6
  /**
6
7
  * Default strategy: Returns only the headers currently visible in the DOM.
@@ -70,10 +71,12 @@ exports.HeaderStrategies = {
70
71
  }
71
72
  }
72
73
  else {
73
- console.warn("HeaderStrategies.horizontalScroll: Could not find scroller. Returning visible headers.");
74
+ (0, debugUtils_1.logDebug)(config, 'info', "HeaderStrategies.horizontalScroll: Could not find scroller. Returning visible headers.");
75
+ }
76
+ if (isScrollerFound) {
77
+ await scrollerHandle.evaluate(el => el.scrollLeft = 0);
78
+ await page.waitForTimeout(200);
74
79
  }
75
- await scrollerHandle.evaluate(el => el.scrollLeft = 0);
76
- await page.waitForTimeout(200);
77
80
  return Array.from(collectedHeaders);
78
81
  };
79
82
  }
@@ -46,16 +46,59 @@ const dataAttribute = (options) => {
46
46
  return { first: Math.min(...indices), last: Math.max(...indices) };
47
47
  }, { rowSel, cellSel, colAttr, colOffset });
48
48
  },
49
+ getVisibleRowIndices: async ({ root, config }) => {
50
+ const rowSel = config.rowSelector;
51
+ // Geometry-based (mirrors getVisibleRowRange's inclusive intersection), but returns
52
+ // the rows' DOM positions so the iteration engine can drop overscan rows (#353/#357).
53
+ return root.evaluate((el, { rowSel, containerSel }) => {
54
+ const rows = Array.from(el.querySelectorAll(rowSel));
55
+ const container = el.closest(containerSel);
56
+ const containerRect = container ? container.getBoundingClientRect() : null;
57
+ const visible = [];
58
+ rows.forEach((r, i) => {
59
+ // Container not resolvable — can't measure, so keep every row (no filtering).
60
+ if (!containerRect) {
61
+ visible.push(i);
62
+ return;
63
+ }
64
+ const rect = r.getBoundingClientRect();
65
+ if (rect.height === 0)
66
+ return; // unrendered / detached
67
+ if (rect.bottom > containerRect.top && rect.top < containerRect.bottom)
68
+ visible.push(i);
69
+ });
70
+ return visible;
71
+ }, { rowSel, containerSel });
72
+ },
49
73
  getVisibleRowRange: async ({ root, config }) => {
50
74
  const rowSel = config.rowSelector;
51
- return root.evaluate((el, { rowSel, rowAttr, rowOffset }) => {
52
- const indices = Array.from(el.querySelectorAll(rowSel))
75
+ return root.evaluate((el, { rowSel, rowAttr, rowOffset, containerSel }) => {
76
+ const rows = Array.from(el.querySelectorAll(rowSel));
77
+ // #353: report only rows actually within the scroll container's vertical
78
+ // bounds, not every mounted row. Previously overscan rows (kept mounted by
79
+ // the virtual scroller above/below the fold) were counted as "visible".
80
+ // Inclusive: a row with ANY vertical overlap counts as visible; only rows
81
+ // entirely above or below the container are dropped — so we never drop a
82
+ // partially-visible real row.
83
+ const container = el.closest(containerSel);
84
+ const containerRect = container ? container.getBoundingClientRect() : null;
85
+ const indices = rows
86
+ .filter(r => {
87
+ // Container not resolvable — can't measure, so keep all rows (safe
88
+ // fallback, preserves pre-#353 behavior rather than risk dropping real rows).
89
+ if (!containerRect)
90
+ return true;
91
+ const rect = r.getBoundingClientRect();
92
+ if (rect.height === 0)
93
+ return false; // unrendered / detached
94
+ return rect.bottom > containerRect.top && rect.top < containerRect.bottom;
95
+ })
53
96
  .map(r => Number(r.getAttribute(rowAttr)) - rowOffset)
54
97
  .filter(n => !isNaN(n));
55
98
  if (!indices.length)
56
99
  return { first: 0, last: 0 };
57
100
  return { first: Math.min(...indices), last: Math.max(...indices) };
58
- }, { rowSel, rowAttr, rowOffset });
101
+ }, { rowSel, rowAttr, rowOffset, containerSel });
59
102
  },
60
103
  scrollToColumn: async ({ root, config }, colIndex) => {
61
104
  const headerSel = typeof config.headerSelector === 'string' ? config.headerSelector : null;