@rickcedwhat/playwright-smart-table 6.25.0-next.c81cb3d → 6.25.0-next.f72ee4c

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.
@@ -24,4 +24,5 @@ export interface CountRowsEnv<T = any> {
24
24
  export declare function countRows<T>(env: CountRowsEnv<T>, filters?: Record<string, FilterValue>, options?: {
25
25
  exact?: boolean;
26
26
  maxPages?: number;
27
+ signal?: AbortSignal;
27
28
  }): Promise<number>;
@@ -40,15 +40,15 @@ function requireMap(tableMapper) {
40
40
  * Callers must have initialized the table.
41
41
  */
42
42
  async function countRows(env, filters, options) {
43
- var _a;
44
- var _b, _c;
43
+ var _a, _b, _c, _d, _e;
44
+ var _f, _g;
45
45
  const { config, rootLocator, tableMapper, rowFinder } = env;
46
46
  const filtersRecord = filters !== null && filters !== void 0 ? filters : {};
47
47
  const hasFilters = Object.keys(filtersRecord).length > 0;
48
- const effectiveMaxPages = (_b = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _b !== void 0 ? _b : config.maxPages;
48
+ const effectiveMaxPages = (_f = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _f !== void 0 ? _f : config.maxPages;
49
49
  const pag = config.strategies.pagination;
50
50
  const hasPagination = effectiveMaxPages > 1 && !!((pag === null || pag === void 0 ? void 0 : pag.goNext) || (pag === null || pag === void 0 ? void 0 : pag.goNextBulk));
51
- const exact = (_c = options === null || options === void 0 ? void 0 : options.exact) !== null && _c !== void 0 ? _c : false;
51
+ const exact = (_g = options === null || options === void 0 ? void 0 : options.exact) !== null && _g !== void 0 ? _g : false;
52
52
  const { domFilters, overrideFilters, syntheticFilters } = partitionFilters(config, filtersRecord);
53
53
  const hasOverrides = Object.keys(overrideFilters).length > 0;
54
54
  const hasSynthetics = Object.keys(syntheticFilters).length > 0;
@@ -80,14 +80,23 @@ async function countRows(env, filters, options) {
80
80
  return;
81
81
  await (0, loadingWait_1.waitWhileTableLoading)(config, env.getContext(), rootLocator.page(), 'countRows');
82
82
  };
83
+ (_b = options === null || options === void 0 ? void 0 : options.signal) === null || _b === void 0 ? void 0 : _b.throwIfAborted();
83
84
  await waitForReady();
85
+ (_c = options === null || options === void 0 ? void 0 : options.signal) === null || _c === void 0 ? void 0 : _c.throwIfAborted();
84
86
  const filterSuffix = hasFilters ? ` filters=${(0, safeStringify_1.safeStringify)(filtersRecord)}` : '';
85
87
  if (!hasPagination) {
86
88
  log(config, `countRows: counting rows in current viewport (no pagination)${filterSuffix}`);
87
- if (!hasPostFilters)
88
- return resolveRows().count();
89
- const all = await resolveRows().all();
90
- return countPostFilterMatches(all, all.map((_, i) => i));
89
+ let count;
90
+ if (!hasPostFilters) {
91
+ count = await resolveRows().count();
92
+ }
93
+ else {
94
+ const all = await resolveRows().all();
95
+ (_d = options === null || options === void 0 ? void 0 : options.signal) === null || _d === void 0 ? void 0 : _d.throwIfAborted();
96
+ count = await countPostFilterMatches(all, all.map((_, i) => i));
97
+ }
98
+ (_e = options === null || options === void 0 ? void 0 : options.signal) === null || _e === void 0 ? void 0 : _e.throwIfAborted();
99
+ return count;
91
100
  }
92
101
  log(config, `countRows: paginating up to ${effectiveMaxPages} page(s)${filterSuffix}`);
93
102
  const tracker = new elementTracker_1.ElementTracker('countRows');
@@ -113,6 +122,7 @@ async function countRows(env, filters, options) {
113
122
  useBulk: false,
114
123
  label: 'countRows',
115
124
  finalScanOnEof: true,
125
+ signal: options === null || options === void 0 ? void 0 : options.signal,
116
126
  });
117
127
  pagesScanned = scanned.pagesScanned;
118
128
  }
@@ -16,4 +16,5 @@ export interface RowByIndexEnv<T = any> {
16
16
  */
17
17
  export declare function findRowByIndex<T>(env: RowByIndexEnv<T>, index: number, options?: {
18
18
  maxPages?: number;
19
+ signal?: AbortSignal;
19
20
  }): Promise<SmartRow<T>>;
@@ -11,7 +11,9 @@ const safeStringify_1 = require("../utils/safeStringify");
11
11
  * mounts (bounded by `maxPages`). Callers must have initialized the table.
12
12
  */
13
13
  async function findRowByIndex(env, index, options) {
14
- var _a;
14
+ var _a, _b, _c, _d, _e;
15
+ var _f;
16
+ (_a = options === null || options === void 0 ? void 0 : options.signal) === null || _a === void 0 ? void 0 : _a.throwIfAborted();
15
17
  const { config } = env;
16
18
  (0, debugUtils_1.logDebug)(config, 'verbose', `findRowByIndex: index=${index} options=${(0, safeStringify_1.safeStringify)(options)}`);
17
19
  const resolveRI = config.strategies.resolveRowIndex;
@@ -25,9 +27,12 @@ async function findRowByIndex(env, index, options) {
25
27
  let foundSelector;
26
28
  // The currently-mounted row whose logical index === index, if any.
27
29
  const findMounted = async () => {
30
+ var _a, _b;
28
31
  const rows = await env.getRowLocators().all();
32
+ (_a = options === null || options === void 0 ? void 0 : options.signal) === null || _a === void 0 ? void 0 : _a.throwIfAborted();
29
33
  for (const r of rows) {
30
34
  const result = await resolveRI(r);
35
+ (_b = options === null || options === void 0 ? void 0 : options.signal) === null || _b === void 0 ? void 0 : _b.throwIfAborted();
31
36
  if (result === undefined)
32
37
  continue;
33
38
  const normalized = (0, rowResolution_1.normalizeRowIndexResult)(result);
@@ -40,22 +45,25 @@ async function findRowByIndex(env, index, options) {
40
45
  };
41
46
  // 1. Already mounted (no navigation needed)?
42
47
  let loc = await findMounted();
48
+ (_b = options === null || options === void 0 ? void 0 : options.signal) === null || _b === void 0 ? void 0 : _b.throwIfAborted();
43
49
  // 2. Fast path: jump straight to it via the viewport's random-access scroll.
44
50
  if (!loc && (viewport === null || viewport === void 0 ? void 0 : viewport.scrollToRow)) {
45
51
  (0, debugUtils_1.logDebug)(config, 'verbose', `findRowByIndex: scrolling to row ${index}`);
46
52
  await viewport.scrollToRow(env.getContext(), index);
53
+ (_c = options === null || options === void 0 ? void 0 : options.signal) === null || _c === void 0 ? void 0 : _c.throwIfAborted();
47
54
  loc = await findMounted();
55
+ (_d = options === null || options === void 0 ? void 0 : options.signal) === null || _d === void 0 ? void 0 : _d.throwIfAborted();
48
56
  }
49
57
  // 3. Fallback: advance pages (on infinite-scroll tables, a "page" is a scroll step) until
50
58
  // the row is mounted. Page 1 was already searched above.
51
- const maxPages = (_a = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _a !== void 0 ? _a : config.maxPages;
59
+ const maxPages = (_f = options === null || options === void 0 ? void 0 : options.maxPages) !== null && _f !== void 0 ? _f : config.maxPages;
52
60
  const scanForMounted = async () => {
53
61
  let hit = null;
54
62
  await (0, scanPages_1.scanPages)({ advancePage: env.advancePage, getCurrentPageIndex: env.getCurrentPageIndex, config }, async ({ pagesScanned }) => {
55
63
  if (pagesScanned > 1)
56
64
  hit = await findMounted();
57
65
  return hit ? 'stop' : 'continue';
58
- }, { maxPages, useBulk: false, label: 'findRowByIndex', finalScanOnEof: false });
66
+ }, { maxPages, useBulk: false, label: 'findRowByIndex', finalScanOnEof: false, signal: options === null || options === void 0 ? void 0 : options.signal });
59
67
  return hit;
60
68
  };
61
69
  if (!loc && config.strategies.pagination) {
@@ -66,5 +74,6 @@ async function findRowByIndex(env, index, options) {
66
74
  `Ensure a viewport.scrollToRow or pagination can bring it into view (searched up to maxPages=${maxPages}).`);
67
75
  }
68
76
  await (0, debugUtils_1.debugDelay)(config, 'findRow');
77
+ (_e = options === null || options === void 0 ? void 0 : options.signal) === null || _e === void 0 ? void 0 : _e.throwIfAborted();
69
78
  return env.makeSmartRow(loc, env.getMap(), index, env.getCurrentPageIndex(), foundSelector);
70
79
  }
@@ -3,7 +3,7 @@ import { FinalTableConfig, Selector, SmartRow, FilterValue } from '../types';
3
3
  import { FilterEngine } from '../filterEngine';
4
4
  import { TableMapper } from './tableMapper';
5
5
  import { SmartRowArray } from '../utils/smartRowArray';
6
- import { NavigationBarrier } from '../utils/navigationBarrier';
6
+ import type { NavigationBarrier } from '../utils/navigationBarrier';
7
7
  export declare class RowFinder<T = any> {
8
8
  private rootLocator;
9
9
  private config;
@@ -23,6 +23,7 @@ export declare class RowFinder<T = any> {
23
23
  findRow(filters: Record<string, FilterValue>, options?: {
24
24
  exact?: boolean;
25
25
  maxPages?: number;
26
+ signal?: AbortSignal;
26
27
  }): Promise<SmartRow<T>>;
27
28
  private waitForTableReady;
28
29
  /** Unfiltered rows keep the plain `SmartRow #n` name that `_makeSmart` applies. */
@@ -31,6 +32,7 @@ export declare class RowFinder<T = any> {
31
32
  exact?: boolean;
32
33
  maxPages?: number;
33
34
  useBulkPagination?: boolean;
35
+ signal?: AbortSignal;
34
36
  }): Promise<SmartRowArray<T>>;
35
37
  private findRowLocator;
36
38
  private resolveRowIndex;
@@ -5,7 +5,6 @@ const debugUtils_1 = require("../utils/debugUtils");
5
5
  const smartRowArray_1 = require("../utils/smartRowArray");
6
6
  const elementTracker_1 = require("../utils/elementTracker");
7
7
  const sentinel_1 = require("../utils/sentinel");
8
- const navigationBarrier_1 = require("../utils/navigationBarrier");
9
8
  const rowResolution_1 = require("./rowResolution");
10
9
  const resolveCellLocator_1 = require("../utils/resolveCellLocator");
11
10
  const scanPages_1 = require("./scanPages");
@@ -172,26 +171,23 @@ class RowFinder {
172
171
  const newIndices = await tracker.getUnseenIndices(rowLocators);
173
172
  const currentRows = await rowLocators.all();
174
173
  let added = 0;
175
- const useBarrier = this.config.concurrency === 'synchronized' && newIndices.length > 1;
176
- const barrier = useBarrier ? new navigationBarrier_1.NavigationBarrier(newIndices.length) : undefined;
177
174
  for (const idx of newIndices) {
178
175
  const resolved = await (0, rowResolution_1.resolveLogicalRowIndex)(currentRows[idx], this.config, () => allRows.length);
179
176
  const rowLocator = this.nameFilteredRow(currentRows[idx], resolved, filtersRecord);
180
- const smartRow = this.makeSmartRow(rowLocator, map, resolved === null || resolved === void 0 ? void 0 : resolved.index, this.tableState.currentPageIndex, barrier, resolved === null || resolved === void 0 ? void 0 : resolved.selector);
177
+ // These rows outlive the scan and may be consumed individually. Only
178
+ // iteration engines that own every callback can attach a batch barrier.
179
+ const smartRow = this.makeSmartRow(rowLocator, map, resolved === null || resolved === void 0 ? void 0 : resolved.index, this.tableState.currentPageIndex, undefined, resolved === null || resolved === void 0 ? void 0 : resolved.selector);
181
180
  const loadingOutcome = await (0, rowResolution_1.resolveRowLoading)(smartRow, this.config.strategies.loading, 'skip', (msg) => (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: ${msg}`));
182
181
  if (loadingOutcome !== 'process') {
183
- barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
184
182
  if (loadingOutcome === 'throw') {
185
183
  throw new Error(`[SmartTable] Row ${allRows.length} did not finish loading within ${(_a = this.config.strategies.loading) === null || _a === void 0 ? void 0 : _a.rowLoadingTimeout}ms`);
186
184
  }
187
185
  continue;
188
186
  }
189
187
  if (hasOverrideFilters && !await this.matchesOverrideFilters(currentRows[idx], overrideFilters, map, (_c = options === null || options === void 0 ? void 0 : options.exact) !== null && _c !== void 0 ? _c : false)) {
190
- barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
191
188
  continue;
192
189
  }
193
190
  if (hasSyntheticFilters && !await this.matchesSyntheticFilters(currentRows[idx], syntheticFilters, map, (_d = options === null || options === void 0 ? void 0 : options.exact) !== null && _d !== void 0 ? _d : false)) {
194
- barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
195
191
  continue;
196
192
  }
197
193
  allRows.push(smartRow);
@@ -204,6 +200,7 @@ class RowFinder {
204
200
  useBulk,
205
201
  label: 'findRows',
206
202
  finalScanOnEof: hasPagination,
203
+ signal: options === null || options === void 0 ? void 0 : options.signal,
207
204
  });
208
205
  (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: done — ${allRows.length} row(s) collected across ${pagesScanned} page(s)`);
209
206
  }
@@ -268,6 +265,7 @@ class RowFinder {
268
265
  useBulk,
269
266
  label: 'findRow',
270
267
  finalScanOnEof: true,
268
+ signal: options.signal,
271
269
  });
272
270
  return found;
273
271
  }
@@ -35,6 +35,15 @@ async function computeSyntheticColumns(env, result, columns) {
35
35
  }
36
36
  }
37
37
  const isBatchRow = (row) => row._inBatch === true || !!row._barrier;
38
+ /** Capture a mounted position's identity once, before readiness or navigation can recycle it. */
39
+ async function resolveSnapshotIdentity(env) {
40
+ if (!env.renderWindowPosition || !env.config.strategies.resolveRowIndex)
41
+ return env;
42
+ const identity = await (0, rowResolution_1.resolveLogicalRowIndex)(env.rowLocator, env.config, () => undefined);
43
+ // Keep the public SmartRow's mounted position unchanged; all snapshot recovery and
44
+ // navigation use this read's logical identity. An unknown identity is not position 0.
45
+ return Object.assign(Object.assign({}, env), { rowIndex: identity === null || identity === void 0 ? void 0 : identity.index, renderWindowPosition: false });
46
+ }
38
47
  // ─── Atomic snapshot ─────────────────────────────────────────────────────────
39
48
  /** Mounted row whose logical index (per resolveRowIndex) is `rowIndex`; tolerates rows detaching mid-scan. */
40
49
  async function rescanForRow(env, rowIndex) {
@@ -107,9 +116,7 @@ async function resolveCloneTarget(env) {
107
116
  await config.strategies.contentReady(cloneTarget, env.rootLocator.page());
108
117
  // Revalidate: the element may have recycled during the await.
109
118
  if (canRepin && await logicalIndexOf(env, cloneTarget) !== rowIndex) {
110
- const reFound = await rescanForRow(env, rowIndex);
111
- if (reFound)
112
- cloneTarget = reFound;
119
+ cloneTarget = await recoverDriftedRow(env, rowIndex);
113
120
  }
114
121
  }
115
122
  return cloneTarget;
@@ -148,6 +155,7 @@ async function readRowAtomic(env, columns) {
148
155
  if (typeof cellSel !== 'string') {
149
156
  throw new Error('[SmartTable] toJSON({ atomic: true }) requires cellSelector to be a CSS string, not a function.');
150
157
  }
158
+ env = await resolveSnapshotIdentity(env);
151
159
  const page = rootLocator.page();
152
160
  const snapshot = await cloneRow(await resolveCloneTarget(env), cellSel);
153
161
  // Determine which columns have overrides (need lazy materialization)
@@ -340,6 +348,7 @@ async function waitForCellLoaded(env, targetCell, col) {
340
348
  */
341
349
  async function readRowLive(env, columns) {
342
350
  var _a, _b, _c;
351
+ env = await resolveSnapshotIdentity(env);
343
352
  const { config, map, rootLocator, rowIndex } = env;
344
353
  const result = {};
345
354
  const page = rootLocator.page();
@@ -52,6 +52,11 @@ export interface ScanPagesOptions {
52
52
  * Matches runMap / findRows EOF behavior. @default true
53
53
  */
54
54
  finalScanOnEof?: boolean;
55
+ /**
56
+ * Checked before each page and before each advance. When aborted, the scan throws
57
+ * `signal.reason` instead of returning partial results (#488).
58
+ */
59
+ signal?: AbortSignal;
55
60
  }
56
61
  export declare function scanPages(env: ScanPagesEnv, onPage: (ctx: ScanPageContext) => Promise<ScanDecision>, options: ScanPagesOptions): Promise<{
57
62
  pagesScanned: number;
@@ -2,6 +2,12 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.scanPages = scanPages;
4
4
  const debugUtils_1 = require("../utils/debugUtils");
5
+ function throwIfAborted(env, label, signal) {
6
+ if (!(signal === null || signal === void 0 ? void 0 : signal.aborted))
7
+ return;
8
+ (0, debugUtils_1.logDebug)(env.config, 'info', `${label}: aborted via AbortSignal at page ${env.getCurrentPageIndex()}`);
9
+ throw signal.reason;
10
+ }
5
11
  async function scanPages(env, onPage, options) {
6
12
  var _a, _b;
7
13
  const maxPages = options.maxPages;
@@ -12,14 +18,17 @@ async function scanPages(env, onPage, options) {
12
18
  let reachedEnd = false;
13
19
  (0, debugUtils_1.logDebug)(env.config, 'verbose', `${label}: starting (maxPages=${maxPages}, useBulk=${useBulk}, finalScanOnEof=${finalScanOnEof})`);
14
20
  while (true) {
21
+ throwIfAborted(env, label, options.signal);
15
22
  if (env.waitForReady) {
16
23
  await env.waitForReady();
24
+ throwIfAborted(env, label, options.signal);
17
25
  }
18
26
  const decision = await onPage({
19
27
  pagesScanned,
20
28
  pageIndex: env.getCurrentPageIndex(),
21
29
  isFinalScan: reachedEnd,
22
30
  });
31
+ throwIfAborted(env, label, options.signal);
23
32
  if (decision === 'stop') {
24
33
  (0, debugUtils_1.logDebug)(env.config, 'verbose', `${label}: onPage returned stop at page ${env.getCurrentPageIndex()}`);
25
34
  break;
@@ -27,8 +36,10 @@ async function scanPages(env, onPage, options) {
27
36
  if (pagesScanned >= maxPages || reachedEnd) {
28
37
  break;
29
38
  }
39
+ throwIfAborted(env, label, options.signal);
30
40
  const prevPage = env.getCurrentPageIndex();
31
41
  const didAdvance = await env.advancePage(useBulk);
42
+ throwIfAborted(env, label, options.signal);
32
43
  if (!didAdvance) {
33
44
  if (finalScanOnEof) {
34
45
  (0, debugUtils_1.logDebug)(env.config, 'verbose', `${label}: pagination returned false — final scan`);
@@ -196,6 +196,7 @@ async function runMap(env, callback, options = {}, label = 'map') {
196
196
  useBulk,
197
197
  label,
198
198
  finalScanOnEof: true,
199
+ signal: options.signal,
199
200
  });
200
201
  log(env.config, `${label}: complete — ${results.length} result(s) across ${pagesScanned} page(s)`);
201
202
  }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { PLAYWRIGHT_SMART_TABLE_VERSION } from './packageVersion';
2
2
  export { useTable } from './useTable';
3
- export type { TableConfig, TableResult, SmartRow, SmartCell, Selector, TableSelector, FilterValue, PaginationPrimitives, SortingStrategy, FillOptions, RowIterationContext, RowIterationOptions, TableContext, StrategyContext, BeforeCellReadFn, GetCellLocatorFn, GetActiveCellFn, DebugConfig, SyntheticColumnDef, ColumnOverride, ColumnOverrideReadContext, TableStrategies, LoadingStrategy, ViewportStrategy, FilterStrategy, NavigationPrimitives, DedupeStrategy, ContentReadyStrategy, FillStrategy, PaginationStrategy, HeaderStrategy, } from './types';
3
+ export type { TableConfig, TableResult, SmartRow, SmartCell, Selector, TableSelector, FilterValue, PaginationPrimitives, SortingStrategy, FillOptions, RowIterationContext, RowIterationOptions, TableContext, StrategyContext, BeforeCellReadFn, GetCellLocatorFn, GetActiveCellFn, DebugConfig, SyntheticColumnDef, ColumnOverride, ColumnOverrideReadContext, TableStrategies, LoadingStrategy, ViewportStrategy, FilterStrategy, NavigationPrimitives, DedupeStrategy, ContentReadyStrategy, RowReadyPredicate, FillStrategy, PaginationStrategy, HeaderStrategy, } from './types';
4
4
  export { Strategies } from './strategies';
5
5
  export * as presets from './presets';
6
6
  /**
@@ -3,4 +3,4 @@
3
3
  * Generated by scripts/embed-version.mjs from package.json
4
4
  */
5
5
  /** Semver of this package. Use in logs or diagnostics to confirm the installed build. */
6
- export declare const PLAYWRIGHT_SMART_TABLE_VERSION: "6.25.0-next.c81cb3d";
6
+ export declare const PLAYWRIGHT_SMART_TABLE_VERSION: "6.25.0-next.f72ee4c";
@@ -6,4 +6,4 @@ exports.PLAYWRIGHT_SMART_TABLE_VERSION = void 0;
6
6
  * Generated by scripts/embed-version.mjs from package.json
7
7
  */
8
8
  /** Semver of this package. Use in logs or diagnostics to confirm the installed build. */
9
- exports.PLAYWRIGHT_SMART_TABLE_VERSION = "6.25.0-next.c81cb3d";
9
+ exports.PLAYWRIGHT_SMART_TABLE_VERSION = "6.25.0-next.f72ee4c";
@@ -1,5 +1,23 @@
1
- import type { ContentReadyStrategy } from '../types';
1
+ import type { ContentReadyStrategy, RowReadyPredicate } from '../types';
2
2
  export declare const ContentReadyStrategies: {
3
+ /**
4
+ * Waits in the browser until `predicate(rowElement, arg)` returns a truthy value, e.g. until
5
+ * a skeleton placeholder is gone or every cell has rendered. Resolves when `timeout` expires
6
+ * even if the predicate never passed, like the other content-ready strategies.
7
+ *
8
+ * Uses `locator.waitForFunction` (Playwright 1.62+), which re-resolves the row on each retry
9
+ * and polls without a round-trip per check. Older versions poll via `locator.evaluate`.
10
+ * `timeout` and `interval` must be positive finite milliseconds. `interval` only controls
11
+ * the older-version fallback; native polling is controlled by Playwright.
12
+ *
13
+ * @example
14
+ * contentReady: Strategies.ContentReady.until(row => !row.querySelector('.skeleton'))
15
+ */
16
+ until: <Arg = undefined>(predicate: RowReadyPredicate<Arg>, options?: {
17
+ arg?: Arg;
18
+ timeout?: number;
19
+ interval?: number;
20
+ }) => ContentReadyStrategy;
3
21
  /**
4
22
  * Polls the row's text content until two consecutive reads match.
5
23
  */
@@ -1,7 +1,88 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ContentReadyStrategies = void 0;
4
+ function isTimeout(error) {
5
+ return (error === null || error === void 0 ? void 0 : error.name) === 'TimeoutError';
6
+ }
7
+ /** Playwright < 1.62 has no `locator.waitForFunction`: poll with one `evaluate` per check. */
8
+ async function pollPredicate(row, page, predicate, arg, timeout, interval) {
9
+ const deadline = Date.now() + timeout;
10
+ let source = predicate.toString();
11
+ // Build a serializable callback; the user predicate executes only inside the browser.
12
+ const wrap = (expression) => new Function(`return async (row, arg) => Boolean(await (${expression})(row, arg))`)();
13
+ let evaluateBoolean;
14
+ try {
15
+ evaluateBoolean = wrap(source);
16
+ }
17
+ catch (_a) {
18
+ // Like Playwright's serializer, normalize method shorthand into a function expression.
19
+ source = source.startsWith('async ') ? source.replace(/^async\s+/, 'async function ') : `function ${source}`;
20
+ evaluateBoolean = wrap(source);
21
+ }
22
+ let expired = false;
23
+ let deadlineTimer;
24
+ const timeoutReached = new Promise(resolve => {
25
+ deadlineTimer = setTimeout(() => { expired = true; resolve(); }, timeout);
26
+ });
27
+ const poll = async () => {
28
+ while (!expired && Date.now() < deadline) {
29
+ // Convert truthiness before serialization, including async DOM-element results.
30
+ if (await row.evaluate(evaluateBoolean, arg, { timeout: Math.max(1, deadline - Date.now()) }))
31
+ return;
32
+ if (expired || Date.now() >= deadline)
33
+ return;
34
+ await page.waitForTimeout(Math.min(interval, Math.max(0, deadline - Date.now())));
35
+ }
36
+ };
37
+ try {
38
+ // evaluate's timeout only bounds locator resolution, not an async predicate's promise.
39
+ await Promise.race([poll(), timeoutReached]);
40
+ }
41
+ finally {
42
+ clearTimeout(deadlineTimer);
43
+ }
44
+ }
4
45
  exports.ContentReadyStrategies = {
46
+ /**
47
+ * Waits in the browser until `predicate(rowElement, arg)` returns a truthy value, e.g. until
48
+ * a skeleton placeholder is gone or every cell has rendered. Resolves when `timeout` expires
49
+ * even if the predicate never passed, like the other content-ready strategies.
50
+ *
51
+ * Uses `locator.waitForFunction` (Playwright 1.62+), which re-resolves the row on each retry
52
+ * and polls without a round-trip per check. Older versions poll via `locator.evaluate`.
53
+ * `timeout` and `interval` must be positive finite milliseconds. `interval` only controls
54
+ * the older-version fallback; native polling is controlled by Playwright.
55
+ *
56
+ * @example
57
+ * contentReady: Strategies.ContentReady.until(row => !row.querySelector('.skeleton'))
58
+ */
59
+ until: (predicate, options = {}) => {
60
+ var _a, _b;
61
+ const timeout = (_a = options.timeout) !== null && _a !== void 0 ? _a : 500;
62
+ const interval = (_b = options.interval) !== null && _b !== void 0 ? _b : 50;
63
+ if (!Number.isFinite(timeout) || timeout <= 0) {
64
+ throw new Error('ContentReady.until: timeout must be a positive finite number');
65
+ }
66
+ if (!Number.isFinite(interval) || interval <= 0) {
67
+ throw new Error('ContentReady.until: interval must be a positive finite number');
68
+ }
69
+ const fn = predicate;
70
+ const arg = options.arg;
71
+ return async (row, page) => {
72
+ try {
73
+ if (typeof row.waitForFunction === 'function') {
74
+ await row.waitForFunction(fn, arg, { timeout });
75
+ }
76
+ else {
77
+ await pollPredicate(row, page, fn, arg, timeout, interval);
78
+ }
79
+ }
80
+ catch (error) {
81
+ if (!isTimeout(error))
82
+ throw error;
83
+ }
84
+ };
85
+ },
5
86
  /**
6
87
  * Polls the row's text content until two consecutive reads match.
7
88
  */
@@ -98,6 +98,11 @@ export declare const Strategies: {
98
98
  dataAttribute: (options?: import("./viewport").DataAttributeViewportOptions) => import("..").ViewportStrategy;
99
99
  };
100
100
  ContentReady: {
101
+ until: <Arg = undefined>(predicate: import("..").RowReadyPredicate<Arg>, options?: {
102
+ arg?: Arg;
103
+ timeout?: number;
104
+ interval?: number;
105
+ }) => import("..").ContentReadyStrategy;
101
106
  textStable: (options?: {
102
107
  timeout?: number;
103
108
  interval?: number;
package/dist/types.d.ts CHANGED
@@ -428,6 +428,8 @@ export interface PaginationPrimitives {
428
428
  export type PaginationStrategy = PaginationPrimitives;
429
429
  export type DedupeStrategy = (row: SmartRow) => string | number | Promise<string | number>;
430
430
  export type ContentReadyStrategy = (row: Locator, page: Page) => Promise<void>;
431
+ /** Predicate for `Strategies.ContentReady.until`. Runs in the browser with the row element, so it must not close over test-side variables; pass values via `arg`. */
432
+ export type RowReadyPredicate<Arg = undefined> = (row: HTMLElement | SVGElement, arg: Arg) => unknown;
431
433
  export type FillStrategy = (options: {
432
434
  row: SmartRow;
433
435
  columnName: string;
@@ -638,12 +640,18 @@ export interface TableStrategies {
638
640
  * Without this, the clone captures stale content from the previous occupant of the
639
641
  * DOM slot — the element is at the correct position but React hasn't re-rendered yet.
640
642
  *
641
- * Use `Strategies.ContentReady.textStable()` for the built-in text-polling strategy.
643
+ * Built-ins: `Strategies.ContentReady.textStable()` (text stops changing),
644
+ * `mutationSettled()` (no DOM mutations for a quiet period), and `until(predicate)`
645
+ * (an in-browser condition holds, e.g. no skeleton placeholder).
642
646
  *
643
647
  * @example
644
648
  * strategies: {
645
649
  * contentReady: Strategies.ContentReady.textStable({ timeout: 500 }),
646
650
  * }
651
+ * @example
652
+ * strategies: {
653
+ * contentReady: Strategies.ContentReady.until(row => !row.querySelector('.skeleton')),
654
+ * }
647
655
  */
648
656
  contentReady?: ContentReadyStrategy;
649
657
  /**
@@ -794,6 +802,11 @@ export type RowIterationOptions = {
794
802
  * @default false — uses goNext for one-page-at-a-time advancement
795
803
  */
796
804
  useBulkPagination?: boolean;
805
+ /**
806
+ * Cancels the iteration between pages. When aborted, the call rejects with
807
+ * `signal.reason` (rows already processed are not returned).
808
+ */
809
+ signal?: AbortSignal;
797
810
  };
798
811
  /**
799
812
  * Result of {@link useTable}. Implements `AsyncIterable` — `for await (const { row } of table)`
@@ -876,31 +889,37 @@ export interface TableResult<T = any> extends AsyncIterable<{
876
889
  * is not configured. Also throws if the row cannot be reached (no silent wrong-row fallback).
877
890
  *
878
891
  * @param index 0-based logical/data-model row index
879
- * @param options - `maxPages` bounds how far to scroll/paginate (defaults to config.maxPages)
892
+ * @param options - `maxPages` bounds how far to scroll/paginate (defaults to config.maxPages).
893
+ * `signal` cancels the page scan; the call rejects with `signal.reason`.
880
894
  */
881
895
  findRowByIndex: (index: number, options?: {
882
896
  maxPages?: number;
897
+ signal?: AbortSignal;
883
898
  }) => Promise<SmartRow<T>>;
884
899
  /**
885
900
  * ASYNC: Searches for a single row across pages using pagination.
886
901
  * Auto-initializes the table if not already initialized.
887
902
  * @param filters - The filter criteria to match
888
- * @param options - Search options including exact match and max pages
903
+ * @param options - Search options including exact match and max pages. `signal` cancels the page scan; the call rejects with `signal.reason`.
889
904
  */
890
905
  findRow: (filters: Record<string, FilterValue>, options?: {
891
906
  exact?: boolean;
892
907
  maxPages?: number;
908
+ signal?: AbortSignal;
893
909
  }) => Promise<SmartRow<T>>;
894
910
  /**
895
911
  * ASYNC: Searches for all matching rows across pages using pagination.
896
912
  * Auto-initializes the table if not already initialized.
913
+ * Returned rows can be read independently, including with `concurrency: 'synchronized'`.
914
+ * Use synchronized `map()` for coordinated column scrolling across virtualized rows.
897
915
  * @param filters - The filter criteria to match (omit or pass {} for all rows)
898
- * @param options - Search options (exact, maxPages). `useBulkPagination` defaults to `false`: pages advance one at a time via `goNext` so no intermediate page is skipped. Set it to `true` to opt into `goNextBulk` (faster, but skips the rows on jumped-over pages).
916
+ * @param options - Search options (exact, maxPages). `useBulkPagination` defaults to `false`: pages advance one at a time via `goNext` so no intermediate page is skipped. Set it to `true` to opt into `goNextBulk` (faster, but skips the rows on jumped-over pages). `signal` cancels the page scan; the call rejects with `signal.reason`.
899
917
  */
900
918
  findRows: (filters?: Record<string, FilterValue>, options?: {
901
919
  exact?: boolean;
902
920
  maxPages?: number;
903
921
  useBulkPagination?: boolean;
922
+ signal?: AbortSignal;
904
923
  }) => Promise<SmartRowArray<T>>;
905
924
  /**
906
925
  * Resolves the named column and scrolls it into view.
@@ -911,11 +930,13 @@ export interface TableResult<T = any> extends AsyncIterable<{
911
930
  /**
912
931
  * Counts the number of rows, optionally filtered.
913
932
  * Without arguments, counts all rows. With filters, counts only matching rows.
914
- * Paginates when pagination is configured.
933
+ * Paginates when pagination is configured. `signal` cancels the page scan; the call
934
+ * rejects with `signal.reason` (the table is still reset to page 1).
915
935
  */
916
936
  countRows: (filters?: Record<string, FilterValue>, options?: {
917
937
  exact?: boolean;
918
938
  maxPages?: number;
939
+ signal?: AbortSignal;
919
940
  }) => Promise<number>;
920
941
  /**
921
942
  * Iterates over rows and extracts the value of a single column.
@@ -4,7 +4,7 @@ import { SmartRow } from '../types';
4
4
  */
5
5
  export interface SmartRowArray<T = any> extends Array<SmartRow<T>> {
6
6
  /**
7
- * Converts all rows to JSON objects
7
+ * Converts all rows to JSON objects, reading sequentially to avoid competing viewport moves.
8
8
  */
9
9
  toJSON(): Promise<T[]>;
10
10
  }
@@ -7,7 +7,10 @@ exports.createSmartRowArray = createSmartRowArray;
7
7
  function createSmartRowArray(rows) {
8
8
  const arr = rows;
9
9
  arr.toJSON = async () => {
10
- return Promise.all(rows.map(r => r.toJSON()));
10
+ const results = [];
11
+ for (const row of rows)
12
+ results.push(await row.toJSON());
13
+ return results;
11
14
  };
12
15
  return arr;
13
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rickcedwhat/playwright-smart-table",
3
- "version": "6.25.0-next.c81cb3d",
3
+ "version": "6.25.0-next.f72ee4c",
4
4
  "description": "Smart, column-aware table interactions for Playwright",
5
5
  "author": "Cedrick Catalan",
6
6
  "license": "MIT",