@rickcedwhat/playwright-smart-table 6.18.0 → 6.19.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.
@@ -167,6 +167,16 @@ export interface ViewportStrategy {
167
167
  */
168
168
  getVisibleRowRange?: (context: TableContext) => Promise<{ first: number; last: number }>;
169
169
 
170
+ /**
171
+ * Returns the DOM positions (0-based, document order — indices into the resolved
172
+ * \`rowSelector\` set) of rows currently within the scroll container's visible bounds.
173
+ * Unlike \`getVisibleRowRange\` (a logical min/max), this identifies the exact rows, so
174
+ * \`map\`/\`forEach\`/\`filter\` can skip overscan rows during collection (see #353 / #357).
175
+ * Geometry-based and inclusive (any overlap counts as visible). When the container can't
176
+ * be measured, return all positions so no rows are filtered.
177
+ */
178
+ getVisibleRowIndices?: (context: TableContext) => Promise<number[]>;
179
+
170
180
  /**
171
181
  * Returns the 0-based index range of columns currently rendered in the DOM.
172
182
  * Used to detect when a target column is not yet mounted before reading.
@@ -254,8 +264,14 @@ export type SmartRow<T = any> = Locator & {
254
264
  *
255
265
  * const partial = await row.toJSON({ columns: ['Name', 'Email'] });
256
266
  * // { Name: 'John', Email: 'john@example.com' }
267
+ *
268
+ * // Atomic: snapshot all cell values in a single evaluate — zero inter-column stagger.
269
+ * // Requires cellSelector to be a CSS string (not a function).
270
+ * // Column overrides ARE supported (run against a frozen off-screen reconstruction).
271
+ * // Uses textContent (not layout-dependent innerText) for non-override columns.
272
+ * const coherent = await row.toJSON({ atomic: true });
257
273
  */
258
- toJSON(options?: { columns?: string[] }): Promise<T>;
274
+ toJSON(options?: { columns?: string[]; atomic?: boolean }): Promise<T>;
259
275
 
260
276
  /**
261
277
  * Scrolls/paginates to bring this row into view.
@@ -424,12 +440,43 @@ export type FillStrategy = (options: {
424
440
  fillOptions?: FillOptions;
425
441
  }) => Promise<void>;
426
442
 
443
+ /** Context passed as the second argument to {@link ColumnOverride.read}. */
444
+ export interface ColumnOverrideReadContext {
445
+ /**
446
+ * The parent row. Use for multi-cell or row-derived values — e.g. a synthetic column
447
+ * that reads an \`a[href]\` or a \`data-*\` attribute from the row rather than a cell:
448
+ * \`read: (_cell, { row }) => row.evaluate(el => el.querySelector('a')?.href)\`.
449
+ */
450
+ row: SmartRow;
451
+ /** The column being read. */
452
+ columnName: string;
453
+ /** The column's 0-based index in the resolved header map. */
454
+ columnIndex: number;
455
+ /**
456
+ * Get a Locator for another cell in the same row by column name.
457
+ * Returns raw cell Locators, not override-processed values.
458
+ *
459
+ * In atomic mode, the Locator points at the frozen reconstructed cell — coherent with
460
+ * every other cell from the same snapshot. In non-atomic mode, it points at the live cell.
461
+ *
462
+ * @example
463
+ * read: async (_cell, { getCell }) => {
464
+ * const name = (await getCell('Name').innerText()).trim();
465
+ * const href = await getCell('Name').locator('a').getAttribute('href') || '';
466
+ * return \`\${name} | \${href}\`;
467
+ * }
468
+ */
469
+ getCell: (columnName: string) => Locator;
470
+ }
471
+
427
472
  export interface ColumnOverride<TValue = any> {
428
- /**
473
+ /**
429
474
  * How to extract the value from the cell.
430
- * \`context\` provides access to the parent row, permitting multi-cell logic or bypassing the default cell locator.
475
+ * The second \`context\` argument provides the parent \`row\` (for multi-cell or row-derived
476
+ * values), plus \`columnName\` and \`columnIndex\`. Backwards-compatible: existing
477
+ * single-argument \`read(cell)\` implementations keep working.
431
478
  */
432
- read?: (cell: Locator) => Promise<TValue> | TValue;
479
+ read?: (cell: Locator, context: ColumnOverrideReadContext) => Promise<TValue> | TValue;
433
480
 
434
481
  /**
435
482
  * How to fill the cell with a new value. (Replaces smartFill default logic)
@@ -620,6 +667,13 @@ export interface TableConfig<T = any> {
620
667
  * Overrides both default extraction (toJSON) and filling (smartFill) logic.
621
668
  */
622
669
  columnOverrides?: Partial<Record<keyof T, ColumnOverride<T[keyof T]>>>;
670
+
671
+ /**
672
+ * Locator for an empty-state element that replaces the table when there are no results.
673
+ * If header resolution fails during init() and this locator is visible, init() succeeds
674
+ * and isEmpty() returns true. All row operations still throw normally.
675
+ */
676
+ emptyState?: Locator;
623
677
  }
624
678
 
625
679
  export interface FinalTableConfig<T = any> extends TableConfig<T> {
@@ -649,9 +703,17 @@ export interface FillOptions {
649
703
  /** Callback context passed to forEach, map, and filter. */
650
704
  export type RowIterationContext<T = any> = {
651
705
  row: SmartRow<T>;
652
- /** 0-based iteration counter — the order this row was visited, not its DOM position or grid identity. @deprecated Use \`index\` instead. \`rowIndex\` will be removed in v7.0.0. */
706
+ /**
707
+ * The row's logical/data-model index. When a \`resolveRowIndex\` strategy is configured
708
+ * (e.g. MUI DataGrid's \`data-rowindex\`) this is the grid's true row index; otherwise it
709
+ * equals \`index\`. Use this for \`row.bringIntoView()\` and position math on virtualized
710
+ * tables — it is stable across scrolling/dedupe, unlike the visit-order \`index\`.
711
+ */
653
712
  rowIndex: number;
654
- /** 0-based iteration counter — the order this row was visited, not its DOM position or grid identity. */
713
+ /**
714
+ * 0-based enumeration counter — the order this row was visited (contiguous within the run).
715
+ * Not a DOM position or grid identity. Use \`rowIndex\` for the row's data-model index.
716
+ */
655
717
  index: number;
656
718
  /** 0-based page index — which page this row was collected from. */
657
719
  pageIndex: number;
@@ -704,6 +766,13 @@ export interface TableResult<T = any> extends AsyncIterable<{ row: SmartRow<T>;
704
766
  */
705
767
  isInitialized(): boolean;
706
768
 
769
+ /**
770
+ * SYNC: Returns true if init() resolved via the emptyState path — the table's
771
+ * empty-state locator was visible when header resolution failed.
772
+ * Row operations still throw normally; use this to branch before calling them.
773
+ */
774
+ isEmpty(): boolean;
775
+
707
776
  getHeaders: () => Promise<string[]>;
708
777
  getHeaderCell: (columnName: string) => Promise<Locator>;
709
778
 
@@ -726,8 +795,9 @@ export interface TableResult<T = any> extends AsyncIterable<{ row: SmartRow<T>;
726
795
  * For button-paginated tables this is the i-th row on the current page. For virtualized
727
796
  * tables the render window shifts as you scroll, so \`getRowByIndex(i)\` returns whatever
728
797
  * row currently sits at DOM position \`i\` — NOT the logical/absolute row \`i\` in the dataset
729
- * once the list has scrolled. To iterate virtualized rows by logical identity, use \`map\` /
730
- * \`findRows\` (with a \`dedupe\` strategy) instead.
798
+ * once the list has scrolled. For the row with a specific logical/data-model index on a
799
+ * virtualized table, use the async \`findRowByIndex(i)\` instead (it scrolls to the row);
800
+ * to iterate by logical identity, use \`map\` / \`findRows\` (with a \`dedupe\` strategy).
731
801
  *
732
802
  * Resolution is lazy: an out-of-range index yields a SmartRow whose operations fail when
733
803
  * it is used, rather than throwing here.
@@ -738,6 +808,26 @@ export interface TableResult<T = any> extends AsyncIterable<{ row: SmartRow<T>;
738
808
  index: number
739
809
  ) => SmartRow<T>;
740
810
 
811
+ /**
812
+ * ASYNC: Returns the row with a specific logical/data-model index, scrolling/paginating to
813
+ * reach it. Unlike the sync \`getRowByIndex\` (render-window position), this resolves the true
814
+ * data-model row \`index\` on virtualized tables.
815
+ *
816
+ * Reaches the row via, in order: a currently-mounted match, the viewport's random-access
817
+ * \`scrollToRow\` fast path, then advancing pages (on infinite-scroll tables a "page" is a
818
+ * scroll step) up to \`maxPages\`.
819
+ *
820
+ * Requires a \`strategies.resolveRowIndex\` to identify rows by logical index — throws if one
821
+ * is not configured. Also throws if the row cannot be reached (no silent wrong-row fallback).
822
+ *
823
+ * @param index 0-based logical/data-model row index
824
+ * @param options - \`maxPages\` bounds how far to scroll/paginate (defaults to config.maxPages)
825
+ */
826
+ findRowByIndex: (
827
+ index: number,
828
+ options?: { maxPages?: number }
829
+ ) => Promise<SmartRow<T>>;
830
+
741
831
  /**
742
832
  * ASYNC: Searches for a single row across pages using pagination.
743
833
  * Auto-initializes the table if not already initialized.
package/dist/types.d.ts CHANGED
@@ -157,6 +157,15 @@ export interface ViewportStrategy {
157
157
  first: number;
158
158
  last: number;
159
159
  }>;
160
+ /**
161
+ * Returns the DOM positions (0-based, document order — indices into the resolved
162
+ * `rowSelector` set) of rows currently within the scroll container's visible bounds.
163
+ * Unlike `getVisibleRowRange` (a logical min/max), this identifies the exact rows, so
164
+ * `map`/`forEach`/`filter` can skip overscan rows during collection (see #353 / #357).
165
+ * Geometry-based and inclusive (any overlap counts as visible). When the container can't
166
+ * be measured, return all positions so no rows are filtered.
167
+ */
168
+ getVisibleRowIndices?: (context: TableContext) => Promise<number[]>;
160
169
  /**
161
170
  * Returns the 0-based index range of columns currently rendered in the DOM.
162
171
  * Used to detect when a target column is not yet mounted before reading.
@@ -236,9 +245,16 @@ export type SmartRow<T = any> = Locator & {
236
245
  *
237
246
  * const partial = await row.toJSON({ columns: ['Name', 'Email'] });
238
247
  * // { Name: 'John', Email: 'john@example.com' }
248
+ *
249
+ * // Atomic: snapshot all cell values in a single evaluate — zero inter-column stagger.
250
+ * // Requires cellSelector to be a CSS string (not a function).
251
+ * // Column overrides ARE supported (run against a frozen off-screen reconstruction).
252
+ * // Uses textContent (not layout-dependent innerText) for non-override columns.
253
+ * const coherent = await row.toJSON({ atomic: true });
239
254
  */
240
255
  toJSON(options?: {
241
256
  columns?: string[];
257
+ atomic?: boolean;
242
258
  }): Promise<T>;
243
259
  /**
244
260
  * Scrolls/paginates to bring this row into view.
@@ -383,12 +399,42 @@ export type FillStrategy = (options: {
383
399
  table: TableResult;
384
400
  fillOptions?: FillOptions;
385
401
  }) => Promise<void>;
402
+ /** Context passed as the second argument to {@link ColumnOverride.read}. */
403
+ export interface ColumnOverrideReadContext {
404
+ /**
405
+ * The parent row. Use for multi-cell or row-derived values — e.g. a synthetic column
406
+ * that reads an `a[href]` or a `data-*` attribute from the row rather than a cell:
407
+ * `read: (_cell, { row }) => row.evaluate(el => el.querySelector('a')?.href)`.
408
+ */
409
+ row: SmartRow;
410
+ /** The column being read. */
411
+ columnName: string;
412
+ /** The column's 0-based index in the resolved header map. */
413
+ columnIndex: number;
414
+ /**
415
+ * Get a Locator for another cell in the same row by column name.
416
+ * Returns raw cell Locators, not override-processed values.
417
+ *
418
+ * In atomic mode, the Locator points at the frozen reconstructed cell — coherent with
419
+ * every other cell from the same snapshot. In non-atomic mode, it points at the live cell.
420
+ *
421
+ * @example
422
+ * read: async (_cell, { getCell }) => {
423
+ * const name = (await getCell('Name').innerText()).trim();
424
+ * const href = await getCell('Name').locator('a').getAttribute('href') || '';
425
+ * return `${name} | ${href}`;
426
+ * }
427
+ */
428
+ getCell: (columnName: string) => Locator;
429
+ }
386
430
  export interface ColumnOverride<TValue = any> {
387
431
  /**
388
432
  * How to extract the value from the cell.
389
- * `context` provides access to the parent row, permitting multi-cell logic or bypassing the default cell locator.
433
+ * The second `context` argument provides the parent `row` (for multi-cell or row-derived
434
+ * values), plus `columnName` and `columnIndex`. Backwards-compatible: existing
435
+ * single-argument `read(cell)` implementations keep working.
390
436
  */
391
- read?: (cell: Locator) => Promise<TValue> | TValue;
437
+ read?: (cell: Locator, context: ColumnOverrideReadContext) => Promise<TValue> | TValue;
392
438
  /**
393
439
  * How to fill the cell with a new value. (Replaces smartFill default logic)
394
440
  * Provides the current value (via `read`) if a `write` wants to check state first.
@@ -567,6 +613,12 @@ export interface TableConfig<T = any> {
567
613
  * Overrides both default extraction (toJSON) and filling (smartFill) logic.
568
614
  */
569
615
  columnOverrides?: Partial<Record<keyof T, ColumnOverride<T[keyof T]>>>;
616
+ /**
617
+ * Locator for an empty-state element that replaces the table when there are no results.
618
+ * If header resolution fails during init() and this locator is visible, init() succeeds
619
+ * and isEmpty() returns true. All row operations still throw normally.
620
+ */
621
+ emptyState?: Locator;
570
622
  }
571
623
  export interface FinalTableConfig<T = any> extends TableConfig<T> {
572
624
  headerSelector: string | ((root: Locator) => Locator);
@@ -595,9 +647,17 @@ export interface FillOptions {
595
647
  /** Callback context passed to forEach, map, and filter. */
596
648
  export type RowIterationContext<T = any> = {
597
649
  row: SmartRow<T>;
598
- /** 0-based iteration counter — the order this row was visited, not its DOM position or grid identity. @deprecated Use `index` instead. `rowIndex` will be removed in v7.0.0. */
650
+ /**
651
+ * The row's logical/data-model index. When a `resolveRowIndex` strategy is configured
652
+ * (e.g. MUI DataGrid's `data-rowindex`) this is the grid's true row index; otherwise it
653
+ * equals `index`. Use this for `row.bringIntoView()` and position math on virtualized
654
+ * tables — it is stable across scrolling/dedupe, unlike the visit-order `index`.
655
+ */
599
656
  rowIndex: number;
600
- /** 0-based iteration counter — the order this row was visited, not its DOM position or grid identity. */
657
+ /**
658
+ * 0-based enumeration counter — the order this row was visited (contiguous within the run).
659
+ * Not a DOM position or grid identity. Use `rowIndex` for the row's data-model index.
660
+ */
601
661
  index: number;
602
662
  /** 0-based page index — which page this row was collected from. */
603
663
  pageIndex: number;
@@ -651,6 +711,12 @@ export interface TableResult<T = any> extends AsyncIterable<{
651
711
  * @returns true if init() has been called and completed, false otherwise
652
712
  */
653
713
  isInitialized(): boolean;
714
+ /**
715
+ * SYNC: Returns true if init() resolved via the emptyState path — the table's
716
+ * empty-state locator was visible when header resolution failed.
717
+ * Row operations still throw normally; use this to branch before calling them.
718
+ */
719
+ isEmpty(): boolean;
654
720
  getHeaders: () => Promise<string[]>;
655
721
  getHeaderCell: (columnName: string) => Promise<Locator>;
656
722
  /**
@@ -670,8 +736,9 @@ export interface TableResult<T = any> extends AsyncIterable<{
670
736
  * For button-paginated tables this is the i-th row on the current page. For virtualized
671
737
  * tables the render window shifts as you scroll, so `getRowByIndex(i)` returns whatever
672
738
  * row currently sits at DOM position `i` — NOT the logical/absolute row `i` in the dataset
673
- * once the list has scrolled. To iterate virtualized rows by logical identity, use `map` /
674
- * `findRows` (with a `dedupe` strategy) instead.
739
+ * once the list has scrolled. For the row with a specific logical/data-model index on a
740
+ * virtualized table, use the async `findRowByIndex(i)` instead (it scrolls to the row);
741
+ * to iterate by logical identity, use `map` / `findRows` (with a `dedupe` strategy).
675
742
  *
676
743
  * Resolution is lazy: an out-of-range index yields a SmartRow whose operations fail when
677
744
  * it is used, rather than throwing here.
@@ -679,6 +746,24 @@ export interface TableResult<T = any> extends AsyncIterable<{
679
746
  * @param index 0-based position within the current render window
680
747
  */
681
748
  getRowByIndex: (index: number) => SmartRow<T>;
749
+ /**
750
+ * ASYNC: Returns the row with a specific logical/data-model index, scrolling/paginating to
751
+ * reach it. Unlike the sync `getRowByIndex` (render-window position), this resolves the true
752
+ * data-model row `index` on virtualized tables.
753
+ *
754
+ * Reaches the row via, in order: a currently-mounted match, the viewport's random-access
755
+ * `scrollToRow` fast path, then advancing pages (on infinite-scroll tables a "page" is a
756
+ * scroll step) up to `maxPages`.
757
+ *
758
+ * Requires a `strategies.resolveRowIndex` to identify rows by logical index — throws if one
759
+ * is not configured. Also throws if the row cannot be reached (no silent wrong-row fallback).
760
+ *
761
+ * @param index 0-based logical/data-model row index
762
+ * @param options - `maxPages` bounds how far to scroll/paginate (defaults to config.maxPages)
763
+ */
764
+ findRowByIndex: (index: number, options?: {
765
+ maxPages?: number;
766
+ }) => Promise<SmartRow<T>>;
682
767
  /**
683
768
  * ASYNC: Searches for a single row across pages using pagination.
684
769
  * Auto-initializes the table if not already initialized.
package/dist/useTable.js CHANGED
@@ -27,6 +27,7 @@ const filterEngine_1 = require("./filterEngine");
27
27
  const tableMapper_1 = require("./engine/tableMapper");
28
28
  const rowFinder_1 = require("./engine/rowFinder");
29
29
  const tableIteration_1 = require("./engine/tableIteration");
30
+ const rowResolution_1 = require("./engine/rowResolution");
30
31
  const debugUtils_1 = require("./utils/debugUtils");
31
32
  const smartRowArray_1 = require("./utils/smartRowArray");
32
33
  const elementTracker_1 = require("./utils/elementTracker");
@@ -69,9 +70,11 @@ const useTable = (rootLocator, configOptions = {}) => {
69
70
  const vp = config.strategies.viewport;
70
71
  let colRangeCache = null;
71
72
  let rowRangeCache = null;
73
+ let rowIndicesCache = null;
72
74
  _clearViewportCache = () => {
73
75
  colRangeCache = null;
74
76
  rowRangeCache = null;
77
+ rowIndicesCache = null;
75
78
  };
76
79
  config.strategies.viewport = Object.assign(Object.assign({}, vp), { getVisibleColumnRange: vp.getVisibleColumnRange
77
80
  ? async (ctx) => {
@@ -85,15 +88,23 @@ const useTable = (rootLocator, configOptions = {}) => {
85
88
  rowRangeCache = await vp.getVisibleRowRange(ctx);
86
89
  return rowRangeCache;
87
90
  }
91
+ : undefined, getVisibleRowIndices: vp.getVisibleRowIndices
92
+ ? async (ctx) => {
93
+ if (!rowIndicesCache)
94
+ rowIndicesCache = await vp.getVisibleRowIndices(ctx);
95
+ return rowIndicesCache;
96
+ }
88
97
  : undefined, scrollToColumn: vp.scrollToColumn
89
98
  ? async (ctx, colIndex) => {
90
99
  colRangeCache = null;
91
100
  rowRangeCache = null;
101
+ rowIndicesCache = null;
92
102
  await vp.scrollToColumn(ctx, colIndex);
93
103
  }
94
104
  : undefined, scrollToRow: vp.scrollToRow
95
105
  ? async (ctx, rowIndex) => {
96
106
  rowRangeCache = null;
107
+ rowIndicesCache = null;
97
108
  await vp.scrollToRow(ctx, rowIndex);
98
109
  }
99
110
  : undefined });
@@ -137,7 +148,7 @@ const useTable = (rootLocator, configOptions = {}) => {
137
148
  const _makeSmart = (rowLocator, map, rowIndex, tablePageIndex, barrier) => {
138
149
  return (0, smartRow_1.default)(rowLocator, map, rowIndex, config, rootLocator, resolve, finalTable, tablePageIndex, barrier);
139
150
  };
140
- const tableState = { currentPageIndex: 0 };
151
+ const tableState = { currentPageIndex: 0, empty: false };
141
152
  const rowFinder = new rowFinder_1.RowFinder(rootLocator, config, resolve, filterEngine, tableMapper, _makeSmart, tableState, (useBulk) => _advancePage(useBulk));
142
153
  /** Builds a full TableContext/StrategyContext with getHeaderCell, getHeaders, scrollToColumn. Set after result is created. */
143
154
  let createStrategyContext = () => ({ root: rootLocator, config, page: rootLocator.page(), resolve });
@@ -215,7 +226,7 @@ const useTable = (rootLocator, configOptions = {}) => {
215
226
  set currentPageIndex(v) { tableState.currentPageIndex = v; },
216
227
  init: async (options) => {
217
228
  var _a;
218
- if (tableMapper.isInitialized())
229
+ if (tableMapper.isInitialized() || tableState.empty)
219
230
  return result;
220
231
  if (config.strategies.sorting)
221
232
  (0, validation_1.validateSortingStrategy)(config.strategies.sorting);
@@ -223,7 +234,21 @@ const useTable = (rootLocator, configOptions = {}) => {
223
234
  (0, validation_1.validateFillStrategy)(config.strategies.fill);
224
235
  (0, debugUtils_1.warnIfDebugInCI)(config);
225
236
  (0, debugUtils_1.logDebug)(config, 'info', 'Initializing table');
226
- const map = await tableMapper.getMap(options === null || options === void 0 ? void 0 : options.timeout);
237
+ let map;
238
+ try {
239
+ map = await tableMapper.getMap(options === null || options === void 0 ? void 0 : options.timeout);
240
+ }
241
+ catch (headerError) {
242
+ if (config.emptyState) {
243
+ const visible = await config.emptyState.isVisible().catch(() => false);
244
+ if (visible) {
245
+ tableState.empty = true;
246
+ (0, debugUtils_1.logDebug)(config, 'info', 'init: header resolution failed but emptyState locator is visible — table is empty');
247
+ return result;
248
+ }
249
+ }
250
+ throw headerError;
251
+ }
227
252
  (0, debugUtils_1.logDebug)(config, 'info', `Table initialized with ${map.size} columns`, Array.from(map.keys()));
228
253
  if ((_a = config.strategies.pagination) === null || _a === void 0 ? void 0 : _a.detectCurrentPage) {
229
254
  try {
@@ -269,6 +294,8 @@ const useTable = (rootLocator, configOptions = {}) => {
269
294
  return resolve(config.headerSelector, rootLocator).nth(idx);
270
295
  },
271
296
  countRows: async () => {
297
+ if (tableState.empty)
298
+ return 0;
272
299
  await _autoInit();
273
300
  const pag = config.strategies.pagination;
274
301
  const hasPagination = config.maxPages > 1 && !!((pag === null || pag === void 0 ? void 0 : pag.goNext) || (pag === null || pag === void 0 ? void 0 : pag.goNextBulk));
@@ -326,7 +353,10 @@ const useTable = (rootLocator, configOptions = {}) => {
326
353
  await cell.bringIntoView();
327
354
  const columnOverride = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[columnName];
328
355
  if (columnOverride === null || columnOverride === void 0 ? void 0 : columnOverride.read) {
329
- return await columnOverride.read(cell);
356
+ return await columnOverride.read(cell, {
357
+ row, columnName, columnIndex: map.get(columnName),
358
+ getCell: (name) => row.getCell(name),
359
+ });
330
360
  }
331
361
  const text = await cell.innerText();
332
362
  return (text || '').trim();
@@ -349,6 +379,7 @@ const useTable = (rootLocator, configOptions = {}) => {
349
379
  log("No goToFirst strategy configured. Table may not be on page 1.");
350
380
  }
351
381
  tableState.currentPageIndex = 0;
382
+ tableState.empty = false;
352
383
  tableMapper.clear();
353
384
  log("Table reset complete. Calling autoInit to restore state.");
354
385
  await _autoInit();
@@ -356,6 +387,7 @@ const useTable = (rootLocator, configOptions = {}) => {
356
387
  revalidate: async () => {
357
388
  log("Revalidating table structure...");
358
389
  await tableMapper.remapHeaders();
390
+ tableState.empty = false;
359
391
  log("Table revalidated.");
360
392
  },
361
393
  getRow: (filters, options = { exact: false }) => {
@@ -375,6 +407,55 @@ const useTable = (rootLocator, configOptions = {}) => {
375
407
  const rowLocator = resolve(config.rowSelector, rootLocator).nth(index);
376
408
  return _makeSmart(rowLocator, map, index);
377
409
  },
410
+ findRowByIndex: async (index, options) => {
411
+ var _a, _b;
412
+ log(`findRowByIndex: index=${index} options=${safeStringify(options)}`);
413
+ await _autoInit();
414
+ const map = tableMapper.getMapSync();
415
+ const resolveRI = config.strategies.resolveRowIndex;
416
+ const viewport = config.strategies.viewport;
417
+ // Identifying a row by its logical index requires a resolveRowIndex strategy. Without one
418
+ // the library can only offer render-window-relative getRowByIndex, or filter-based findRow.
419
+ if (!resolveRI) {
420
+ throw new Error(`findRowByIndex(${index}) requires a strategies.resolveRowIndex to identify rows by their logical index. ` +
421
+ `Configure one (e.g. read the grid's row-index attribute), or use getRowByIndex() (current render window) / findRow(filters).`);
422
+ }
423
+ // The currently-mounted row whose logical index === index, if any.
424
+ const findMounted = async () => {
425
+ const rows = await resolve(config.rowSelector, rootLocator).all();
426
+ for (const r of rows) {
427
+ if ((await resolveRI(r)) === index)
428
+ return r;
429
+ }
430
+ return null;
431
+ };
432
+ // 1. Already mounted (no navigation needed)?
433
+ let loc = await findMounted();
434
+ // 2. Fast path: jump straight to it via the viewport's random-access scroll.
435
+ if (!loc && (viewport === null || viewport === void 0 ? void 0 : viewport.scrollToRow)) {
436
+ log(`findRowByIndex: scrolling to row ${index}`);
437
+ await viewport.scrollToRow(createStrategyContext(), index);
438
+ loc = await findMounted();
439
+ }
440
+ // 3. Fallback: advance pages (on infinite-scroll tables, a "page" is a scroll step) until
441
+ // the row is reached. Bounded by maxPages so an unreachable index can't loop forever.
442
+ if (!loc && config.strategies.pagination) {
443
+ const effectiveMaxPages = (_a = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _a !== void 0 ? _a : config.maxPages;
444
+ let pagesScanned = 1;
445
+ while (!loc && pagesScanned < effectiveMaxPages) {
446
+ if (!await _advancePage(false))
447
+ break;
448
+ pagesScanned++;
449
+ loc = await findMounted();
450
+ }
451
+ }
452
+ if (!loc) {
453
+ throw new Error(`findRowByIndex(${index}): could not reach row ${index}. ` +
454
+ `Ensure a viewport.scrollToRow or pagination can bring it into view (searched up to maxPages=${(_b = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _b !== void 0 ? _b : config.maxPages}).`);
455
+ }
456
+ await (0, debugUtils_1.debugDelay)(config, 'findRow');
457
+ return _makeSmart(loc, map, index, tableState.currentPageIndex);
458
+ },
378
459
  findRow: async (filters, options) => {
379
460
  log(`findRow: filters=${safeStringify(filters)} options=${safeStringify(options)}`);
380
461
  return rowFinder.findRow(filters, options);
@@ -384,10 +465,13 @@ const useTable = (rootLocator, configOptions = {}) => {
384
465
  return rowFinder.findRows(filters !== null && filters !== void 0 ? filters : {}, options);
385
466
  },
386
467
  isInitialized: () => {
387
- const initialized = tableMapper.isInitialized();
468
+ const initialized = tableMapper.isInitialized() || tableState.empty;
388
469
  log(`isInitialized: ${initialized}`);
389
470
  return initialized;
390
471
  },
472
+ isEmpty: () => {
473
+ return tableState.empty;
474
+ },
391
475
  sorting: {
392
476
  apply: async (columnName, direction) => {
393
477
  var _a;
@@ -434,6 +518,7 @@ const useTable = (rootLocator, configOptions = {}) => {
434
518
  // ─── Shared async row iterator ───────────────────────────────────────────
435
519
  [Symbol.asyncIterator]() {
436
520
  return __asyncGenerator(this, arguments, function* _a() {
521
+ var _b;
437
522
  yield __await(_autoInit());
438
523
  const map = tableMapper.getMapSync();
439
524
  const effectiveMaxPages = config.maxPages;
@@ -449,7 +534,13 @@ const useTable = (rootLocator, configOptions = {}) => {
449
534
  const pageRows = yield __await(rowLocators.all());
450
535
  const barrier = new navigationBarrier_1.NavigationBarrier(newIndices.length);
451
536
  for (const idx of newIndices) {
452
- yield yield __await({ row: _makeSmart(pageRows[idx], map, rowIndex, tableState.currentPageIndex, barrier), index: rowIndex, rowIndex, pageIndex: tableState.currentPageIndex });
537
+ // index = visit-order counter; rowIndex = logical/data index (B-hybrid, #362).
538
+ const logical = (_b = yield __await((0, rowResolution_1.resolveLogicalRowIndex)(pageRows[idx], config, () => rowIndex))) !== null && _b !== void 0 ? _b : rowIndex;
539
+ const sr = _makeSmart(pageRows[idx], map, logical, tableState.currentPageIndex, barrier);
540
+ // Iterator rows share one per-page DOM snapshot (positional locators); disable
541
+ // toJSON's scroll-back recovery so reading one row can't invalidate the others (#366).
542
+ sr._inBatch = true;
543
+ yield yield __await({ row: sr, index: rowIndex, rowIndex: logical, pageIndex: tableState.currentPageIndex });
453
544
  rowIndex++;
454
545
  }
455
546
  if (pagesScanned >= effectiveMaxPages)
@@ -477,6 +568,7 @@ const useTable = (rootLocator, configOptions = {}) => {
477
568
  config,
478
569
  getPage: () => rootLocator.page(),
479
570
  getCurrentPageIndex: () => tableState.currentPageIndex,
571
+ getContext: () => createStrategyContext(),
480
572
  }, callback, options);
481
573
  },
482
574
  map: async (callback, options = {}) => {
@@ -491,6 +583,7 @@ const useTable = (rootLocator, configOptions = {}) => {
491
583
  config,
492
584
  getPage: () => rootLocator.page(),
493
585
  getCurrentPageIndex: () => tableState.currentPageIndex,
586
+ getContext: () => createStrategyContext(),
494
587
  }, callback, options);
495
588
  },
496
589
  toArray: async (callback, options = {}) => {
@@ -515,6 +608,7 @@ const useTable = (rootLocator, configOptions = {}) => {
515
608
  config,
516
609
  getPage: () => rootLocator.page(),
517
610
  getCurrentPageIndex: () => tableState.currentPageIndex,
611
+ getContext: () => createStrategyContext(),
518
612
  }, predicate, options);
519
613
  },
520
614
  generateConfig: async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rickcedwhat/playwright-smart-table",
3
- "version": "6.18.0",
3
+ "version": "6.19.0",
4
4
  "description": "Smart, column-aware table interactions for Playwright",
5
5
  "author": "Cedrick Catalan",
6
6
  "license": "MIT",