@rickcedwhat/playwright-smart-table 6.24.0 → 6.25.0-next.1e71a54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/engine/cellNavigation.d.ts +24 -0
  2. package/dist/engine/cellNavigation.js +326 -0
  3. package/dist/engine/countRows.d.ts +28 -0
  4. package/dist/engine/countRows.js +149 -0
  5. package/dist/engine/rowByIndex.d.ts +20 -0
  6. package/dist/engine/rowByIndex.js +79 -0
  7. package/dist/engine/rowFinder.d.ts +5 -1
  8. package/dist/engine/rowFinder.js +17 -10
  9. package/dist/engine/rowResolution.d.ts +2 -2
  10. package/dist/engine/rowResolution.js +11 -7
  11. package/dist/engine/rowSnapshot.d.ts +28 -0
  12. package/dist/engine/rowSnapshot.js +422 -0
  13. package/dist/engine/scanPages.d.ts +7 -2
  14. package/dist/engine/scanPages.js +11 -0
  15. package/dist/engine/sorting.d.ts +11 -0
  16. package/dist/engine/sorting.js +51 -0
  17. package/dist/engine/tableIteration.js +3 -1
  18. package/dist/engine/tableMapper.js +16 -5
  19. package/dist/index.d.ts +1 -1
  20. package/dist/packageVersion.d.ts +1 -1
  21. package/dist/packageVersion.js +1 -1
  22. package/dist/presets/glide/index.js +0 -1
  23. package/dist/presets/rdg.js +0 -2
  24. package/dist/smartRow.js +51 -721
  25. package/dist/strategies/columns.d.ts +1 -1
  26. package/dist/strategies/contentReady.d.ts +19 -2
  27. package/dist/strategies/contentReady.js +81 -0
  28. package/dist/strategies/index.d.ts +7 -3
  29. package/dist/strategies/resolution.d.ts +1 -3
  30. package/dist/strategies/resolution.js +0 -28
  31. package/dist/types.d.ts +40 -7
  32. package/dist/useTable.js +51 -241
  33. package/dist/utils/deadline.d.ts +2 -0
  34. package/dist/utils/deadline.js +19 -0
  35. package/dist/utils/locatorDescription.d.ts +15 -0
  36. package/dist/utils/locatorDescription.js +56 -0
  37. package/dist/utils/navigationBarrier.d.ts +0 -2
  38. package/dist/utils/navigationBarrier.js +0 -4
  39. package/dist/utils/safeStringify.d.ts +2 -0
  40. package/dist/utils/safeStringify.js +18 -0
  41. package/dist/utils/smartRowArray.d.ts +1 -1
  42. package/dist/utils/smartRowArray.js +4 -1
  43. package/package.json +5 -6
  44. package/dist/typeContext.d.ts +0 -6
  45. package/dist/typeContext.js +0 -1133
  46. package/dist/utils.d.ts +0 -7
  47. package/dist/utils.js +0 -21
@@ -5,10 +5,10 @@ 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");
11
+ const locatorDescription_1 = require("../utils/locatorDescription");
12
12
  const loadingWait_1 = require("../utils/loadingWait");
13
13
  class RowFinder {
14
14
  constructor(rootLocator, config, resolve, filterEngine, tableMapper, makeSmartRow, tableState = { currentPageIndex: 0 }, advancePage = async () => false) {
@@ -111,12 +111,13 @@ class RowFinder {
111
111
  await (0, debugUtils_1.debugDelay)(this.config, 'findRow');
112
112
  const map = await this.tableMapper.getMap();
113
113
  const resolved = await this.resolveRowIndex(rowLocator);
114
- return 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);
114
+ const describedRow = (0, locatorDescription_1.describeLocator)(rowLocator, (0, locatorDescription_1.describeRowByFilters)(filters), this.config);
115
+ return this.makeSmartRow(describedRow, map, resolved === null || resolved === void 0 ? void 0 : resolved.index, this.tableState.currentPageIndex, undefined, resolved === null || resolved === void 0 ? void 0 : resolved.selector);
115
116
  }
116
117
  (0, debugUtils_1.logDebug)(this.config, 'error', 'Row not found', filters);
117
118
  await (0, debugUtils_1.debugDelay)(this.config, 'findRow');
118
- const sentinel = this.resolve(this.config.rowSelector, this.rootLocator)
119
- .filter({ hasText: "___SENTINEL_ROW_NOT_FOUND___" + Date.now() });
119
+ const sentinel = (0, locatorDescription_1.describeLocator)(this.resolve(this.config.rowSelector, this.rootLocator)
120
+ .filter({ hasText: "___SENTINEL_ROW_NOT_FOUND___" + Date.now() }), (0, locatorDescription_1.describeSentinelRow)(filters), this.config);
120
121
  const smartRow = this.makeSmartRow(sentinel, await this.tableMapper.getMap(), undefined);
121
122
  smartRow[sentinel_1.SENTINEL_ROW] = true;
122
123
  return smartRow;
@@ -134,6 +135,12 @@ class RowFinder {
134
135
  };
135
136
  await (0, loadingWait_1.waitWhileTableLoading)(this.config, context, this.rootLocator.page(), 'waitForTableReady');
136
137
  }
138
+ /** Unfiltered rows keep the plain `SmartRow #n` name that `_makeSmart` applies. */
139
+ nameFilteredRow(row, resolved, filters) {
140
+ if (!resolved || Object.keys(filters).length === 0)
141
+ return row;
142
+ return (0, locatorDescription_1.describeLocator)(row, (0, locatorDescription_1.describeRowByIndex)(resolved.index, filters), this.config);
143
+ }
137
144
  async findRows(filters = {}, options) {
138
145
  var _a;
139
146
  var _b, _c;
@@ -164,25 +171,23 @@ class RowFinder {
164
171
  const newIndices = await tracker.getUnseenIndices(rowLocators);
165
172
  const currentRows = await rowLocators.all();
166
173
  let added = 0;
167
- const useBarrier = this.config.concurrency === 'synchronized' && newIndices.length > 1;
168
- const barrier = useBarrier ? new navigationBarrier_1.NavigationBarrier(newIndices.length) : undefined;
169
174
  for (const idx of newIndices) {
170
175
  const resolved = await (0, rowResolution_1.resolveLogicalRowIndex)(currentRows[idx], this.config, () => allRows.length);
171
- const smartRow = this.makeSmartRow(currentRows[idx], map, resolved === null || resolved === void 0 ? void 0 : resolved.index, this.tableState.currentPageIndex, barrier, resolved === null || resolved === void 0 ? void 0 : resolved.selector);
176
+ const rowLocator = this.nameFilteredRow(currentRows[idx], resolved, filtersRecord);
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);
172
180
  const loadingOutcome = await (0, rowResolution_1.resolveRowLoading)(smartRow, this.config.strategies.loading, 'skip', (msg) => (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: ${msg}`));
173
181
  if (loadingOutcome !== 'process') {
174
- barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
175
182
  if (loadingOutcome === 'throw') {
176
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`);
177
184
  }
178
185
  continue;
179
186
  }
180
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)) {
181
- barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
182
188
  continue;
183
189
  }
184
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)) {
185
- barrier === null || barrier === void 0 ? void 0 : barrier.markFinished();
186
191
  continue;
187
192
  }
188
193
  allRows.push(smartRow);
@@ -195,6 +200,7 @@ class RowFinder {
195
200
  useBulk,
196
201
  label: 'findRows',
197
202
  finalScanOnEof: hasPagination,
203
+ signal: options === null || options === void 0 ? void 0 : options.signal,
198
204
  });
199
205
  (0, debugUtils_1.logDebug)(this.config, 'verbose', `findRows: done — ${allRows.length} row(s) collected across ${pagesScanned} page(s)`);
200
206
  }
@@ -259,6 +265,7 @@ class RowFinder {
259
265
  useBulk,
260
266
  label: 'findRow',
261
267
  finalScanOnEof: true,
268
+ signal: options.signal,
262
269
  });
263
270
  return found;
264
271
  }
@@ -29,7 +29,7 @@ export type RowLoadingOutcome = 'process' | 'skip' | 'throw';
29
29
  * 'skip'`), while `map`/`forEach`/`filter` process it as-is (`'process'`) — and map, in that
30
30
  * case, never even probes `isRowLoading`. Both are preserved exactly.
31
31
  *
32
- * The helper never throws and never touches the navigation barrier; it returns a decision so
33
- * each caller keeps its own barrier bookkeeping and error message.
32
+ * Timeout returns a decision so each caller keeps its own barrier bookkeeping and error
33
+ * message. Predicate errors propagate unchanged.
34
34
  */
35
35
  export declare function resolveRowLoading(row: SmartRow, loading: LoadingStrategy | undefined, noTimeoutAction: 'skip' | 'process', log?: (msg: string) => void): Promise<RowLoadingOutcome>;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.normalizeRowIndexResult = normalizeRowIndexResult;
4
4
  exports.resolveLogicalRowIndex = resolveLogicalRowIndex;
5
5
  exports.resolveRowLoading = resolveRowLoading;
6
+ const deadline_1 = require("../utils/deadline");
6
7
  /** Extract the numeric index from a {@link RowIndexResult}. */
7
8
  function normalizeRowIndexResult(result) {
8
9
  return typeof result === 'number' ? { index: result } : result;
@@ -36,8 +37,8 @@ async function resolveLogicalRowIndex(row, config, fallback) {
36
37
  * 'skip'`), while `map`/`forEach`/`filter` process it as-is (`'process'`) — and map, in that
37
38
  * case, never even probes `isRowLoading`. Both are preserved exactly.
38
39
  *
39
- * The helper never throws and never touches the navigation barrier; it returns a decision so
40
- * each caller keeps its own barrier bookkeeping and error message.
40
+ * Timeout returns a decision so each caller keeps its own barrier bookkeeping and error
41
+ * message. Predicate errors propagate unchanged.
41
42
  */
42
43
  async function resolveRowLoading(row, loading, noTimeoutAction, log) {
43
44
  var _a;
@@ -53,18 +54,21 @@ async function resolveRowLoading(row, loading, noTimeoutAction, log) {
53
54
  // map's fast path: with no timeout and a process-default, it never probes isRowLoading.
54
55
  if (timeout === undefined && noTimeoutAction === 'process')
55
56
  return 'process';
56
- if (!(await isRowLoading(row)))
57
+ const deadline = Date.now() + (timeout !== null && timeout !== void 0 ? timeout : Infinity);
58
+ const check = () => (0, deadline_1.withinDeadline)(() => isRowLoading(row), deadline, true);
59
+ if (!(await check()))
57
60
  return 'process';
58
61
  // Row is loading with no timeout configured → legacy skip (findRows).
59
62
  if (timeout === undefined)
60
63
  return 'skip';
61
64
  log === null || log === void 0 ? void 0 : log(`row ${row.rowIndex} — waiting up to ${timeout}ms for row to load`);
62
- const deadline = Date.now() + timeout;
63
- let resolved = !(await isRowLoading(row)); // immediate check (handles timeout=0)
65
+ let resolved = (timeout === 0 || Date.now() < deadline) && !(await check());
64
66
  while (!resolved && Date.now() < deadline) {
65
67
  // Page accessed lazily (only while actually polling), matching the pre-refactor code.
66
- await row.page().waitForTimeout(100);
67
- resolved = !(await isRowLoading(row));
68
+ await row.page().waitForTimeout(Math.min(100, deadline - Date.now()));
69
+ if (Date.now() >= deadline)
70
+ break;
71
+ resolved = !(await check());
68
72
  }
69
73
  if (resolved)
70
74
  return 'process';
@@ -0,0 +1,28 @@
1
+ import type { Locator, Page } from '@playwright/test';
2
+ import type { FinalTableConfig, SmartRow, TableResult } from '../types';
3
+ import type { SmartRowInternals } from '../utils/smartRowInternals';
4
+ /** Everything `toJSON` needs from the SmartRow being read. */
5
+ export interface RowSnapshotEnv<T = any> {
6
+ config: FinalTableConfig<T>;
7
+ rootLocator: Locator;
8
+ resolve: (item: any, parent: Locator | Page) => Locator;
9
+ map: Map<string, number>;
10
+ row: SmartRow<T> & SmartRowInternals;
11
+ rowLocator: Locator;
12
+ rowIndex: number | undefined;
13
+ table: TableResult<T> | null;
14
+ renderWindowPosition: boolean;
15
+ }
16
+ /** `getHeaderCell` for strategy hooks: the table's when available, else an index lookup. */
17
+ export declare function headerCellGetter<T>(env: Pick<RowSnapshotEnv<T>, 'config' | 'rootLocator' | 'resolve' | 'map' | 'table'>): (columnName: string) => Promise<Locator>;
18
+ /**
19
+ * `toJSON({ atomic: true })`: snapshot the row in a single evaluate, then apply column
20
+ * overrides against a frozen reconstruction. Zero inter-column stagger — even in-place
21
+ * React re-renders can't affect it because overrides read from a detached clone.
22
+ */
23
+ export declare function readRowAtomic<T>(env: RowSnapshotEnv<T>, columns?: string[]): Promise<T>;
24
+ /**
25
+ * Default `toJSON()`: reads each column from the live DOM, navigating to it first
26
+ * (keeps synchronized batches in lock-step) and re-pinning the row before each read.
27
+ */
28
+ export declare function readRowLive<T>(env: RowSnapshotEnv<T>, columns?: string[]): Promise<T>;
@@ -0,0 +1,422 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.headerCellGetter = headerCellGetter;
4
+ exports.readRowAtomic = readRowAtomic;
5
+ exports.readRowLive = readRowLive;
6
+ const rowResolution_1 = require("./rowResolution");
7
+ const cellNavigation_1 = require("./cellNavigation");
8
+ const debugUtils_1 = require("../utils/debugUtils");
9
+ const deadline_1 = require("../utils/deadline");
10
+ /** `getHeaderCell` for strategy hooks: the table's when available, else an index lookup. */
11
+ function headerCellGetter(env) {
12
+ const { table } = env;
13
+ return (table === null || table === void 0 ? void 0 : table.getHeaderCell)
14
+ ? table.getHeaderCell.bind(table)
15
+ : async (colName) => {
16
+ const idx = env.map.get(colName);
17
+ if (idx === undefined)
18
+ throw new Error(`Column "${colName}" not found`);
19
+ return env.resolve(env.config.headerSelector, env.rootLocator).nth(idx);
20
+ };
21
+ }
22
+ /** Adds synthetic columns to `result`; their compute() reads already-read columns from `result`. */
23
+ async function computeSyntheticColumns(env, result, columns) {
24
+ var _a;
25
+ const smart = env.row;
26
+ for (const [name, def] of Object.entries((_a = env.config.syntheticColumns) !== null && _a !== void 0 ? _a : {})) {
27
+ if (columns && !columns.includes(name))
28
+ continue;
29
+ const snapshotRow = Object.create(smart);
30
+ snapshotRow.getValue = async (col) => {
31
+ if (col in result)
32
+ return String(result[col]);
33
+ return smart.getValue(col);
34
+ };
35
+ result[name] = String(await def.compute(snapshotRow));
36
+ }
37
+ }
38
+ const isBatchRow = (row) => row._inBatch === true || !!row._barrier;
39
+ /** Capture a mounted position's identity once, before readiness or navigation can recycle it. */
40
+ async function resolveSnapshotIdentity(env) {
41
+ if (!env.renderWindowPosition || !env.config.strategies.resolveRowIndex)
42
+ return env;
43
+ const identity = await (0, rowResolution_1.resolveLogicalRowIndex)(env.rowLocator, env.config, () => undefined);
44
+ // Keep the public SmartRow's mounted position unchanged; all snapshot recovery and
45
+ // navigation use this read's logical identity. An unknown identity is not position 0.
46
+ return Object.assign(Object.assign({}, env), { rowIndex: identity === null || identity === void 0 ? void 0 : identity.index, renderWindowPosition: false });
47
+ }
48
+ // ─── Atomic snapshot ─────────────────────────────────────────────────────────
49
+ /** Mounted row whose logical index (per resolveRowIndex) is `rowIndex`; tolerates rows detaching mid-scan. */
50
+ async function rescanForRow(env, rowIndex) {
51
+ const resolveRI = env.config.strategies.resolveRowIndex;
52
+ const rows = await env.resolve(env.config.rowSelector, env.rootLocator).all();
53
+ for (const r of rows) {
54
+ try {
55
+ const rResult = await resolveRI(r);
56
+ if (rResult !== undefined && (0, rowResolution_1.normalizeRowIndexResult)(rResult).index === rowIndex)
57
+ return r;
58
+ }
59
+ catch (_a) { /* element may have detached during scan */ }
60
+ }
61
+ return null;
62
+ }
63
+ async function logicalIndexOf(env, loc) {
64
+ try {
65
+ const r = await env.config.strategies.resolveRowIndex(loc);
66
+ return r !== undefined ? (0, rowResolution_1.normalizeRowIndexResult)(r).index : undefined;
67
+ }
68
+ catch (_a) {
69
+ return undefined;
70
+ }
71
+ }
72
+ const recycledRowHint = (inBatch, batchNote, otherwise) => inBatch ? batchNote : otherwise;
73
+ /** Scrolls the row back into view and rescans for it for up to 500ms. */
74
+ async function scrollBackAndRescan(env, rowIndex) {
75
+ var _a;
76
+ const { config, rootLocator, resolve } = env;
77
+ const scrollToRow = (_a = config.strategies.viewport) === null || _a === void 0 ? void 0 : _a.scrollToRow;
78
+ if (!scrollToRow)
79
+ return null;
80
+ const page = rootLocator.page();
81
+ await scrollToRow({ root: rootLocator, config, page, resolve }, rowIndex);
82
+ const deadline = Date.now() + 500;
83
+ let recovered = null;
84
+ while (!recovered && Date.now() < deadline) {
85
+ recovered = await rescanForRow(env, rowIndex);
86
+ if (!recovered)
87
+ await page.waitForTimeout(50);
88
+ }
89
+ return recovered;
90
+ }
91
+ /**
92
+ * Re-pin: the row locator is a positional nth-child from the iteration loop.
93
+ * On recycling virtualizers, elements can shift between SmartRow creation and
94
+ * the clone — the nth element may now show a different row's content.
95
+ * Verify via resolveRowIndex and rescan if drifted.
96
+ */
97
+ async function recoverDriftedRow(env, rowIndex) {
98
+ const inBatch = isBatchRow(env.row);
99
+ let recovered = await rescanForRow(env, rowIndex);
100
+ if (!recovered && !inBatch)
101
+ recovered = await scrollBackAndRescan(env, rowIndex);
102
+ if (recovered)
103
+ return recovered;
104
+ throw new Error(`[SmartTable] toJSON({ atomic: true }): row ${rowIndex} recycled out of the DOM and could not be recovered. ` +
105
+ recycledRowHint(inBatch, `(During a map/forEach batch, scroll-back recovery is disabled to avoid disrupting sibling rows.)`, `Ensure a viewport.scrollToRow strategy can restore the row.`));
106
+ }
107
+ /** The element to clone: the row itself, re-pinned if it drifted, after `contentReady` settles. */
108
+ async function resolveCloneTarget(env) {
109
+ const { config, rowIndex } = env;
110
+ const canRepin = !!config.strategies.resolveRowIndex && typeof rowIndex === 'number' && env.row._selfHealing !== true;
111
+ let cloneTarget = env.rowLocator;
112
+ if (canRepin && await logicalIndexOf(env, cloneTarget) !== rowIndex) {
113
+ cloneTarget = await recoverDriftedRow(env, rowIndex);
114
+ }
115
+ // Wait for async content renders to settle before cloning.
116
+ if (config.strategies.contentReady) {
117
+ await config.strategies.contentReady(cloneTarget, env.rootLocator.page());
118
+ // Revalidate: the element may have recycled during the await.
119
+ if (canRepin && await logicalIndexOf(env, cloneTarget) !== rowIndex) {
120
+ cloneTarget = await recoverDriftedRow(env, rowIndex);
121
+ }
122
+ }
123
+ return cloneTarget;
124
+ }
125
+ /**
126
+ * Clones the row in browser memory (NOT in the DOM) and extracts text + row HTML.
127
+ * Uses textContent (not innerText) because the clone is detached — innerText
128
+ * falls back to textContent on detached nodes per HTML spec, so be explicit.
129
+ */
130
+ async function cloneRow(target, cellSel) {
131
+ const cloneHandle = await target.evaluateHandle(el => el.cloneNode(true));
132
+ try {
133
+ return await cloneHandle.evaluate((row, sel) => {
134
+ const cells = row.querySelectorAll(sel);
135
+ return {
136
+ rowHTML: row.outerHTML,
137
+ cells: [...cells].map(c => ({
138
+ text: (c.textContent || '').trim(),
139
+ })),
140
+ };
141
+ }, cellSel);
142
+ }
143
+ finally {
144
+ await cloneHandle.dispose();
145
+ }
146
+ }
147
+ /**
148
+ * `toJSON({ atomic: true })`: snapshot the row in a single evaluate, then apply column
149
+ * overrides against a frozen reconstruction. Zero inter-column stagger — even in-place
150
+ * React re-renders can't affect it because overrides read from a detached clone.
151
+ */
152
+ async function readRowAtomic(env, columns) {
153
+ var _a, _b;
154
+ const { config, map, rootLocator } = env;
155
+ const cellSel = config.cellSelector;
156
+ if (typeof cellSel !== 'string') {
157
+ throw new Error('[SmartTable] toJSON({ atomic: true }) requires cellSelector to be a CSS string, not a function.');
158
+ }
159
+ env = await resolveSnapshotIdentity(env);
160
+ const page = rootLocator.page();
161
+ const snapshot = await cloneRow(await resolveCloneTarget(env), cellSel);
162
+ // Determine which columns have overrides (need lazy materialization)
163
+ const columnsToProcess = [...map.entries()].filter(([col]) => !columns || columns.includes(col));
164
+ 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); });
165
+ // Lazily materialize the row if any override needs a Locator.
166
+ // Uses position:fixed off-screen (NOT display:none — that breaks innerText).
167
+ const snapId = `__st_snap_${Math.random().toString(36).slice(2, 10)}`;
168
+ const snapCell = (colIdx) => page.locator(`#${snapId}`).locator(cellSel).nth(colIdx);
169
+ const getCell = (colName) => {
170
+ const colIdx = map.get(colName);
171
+ if (colIdx === undefined)
172
+ throw new Error(`[SmartTable] getCell: column "${colName}" not found`);
173
+ return snapCell(colIdx);
174
+ };
175
+ if (hasAnyOverride) {
176
+ await page.evaluate(([html, id]) => {
177
+ const c = document.createElement('div');
178
+ c.id = id;
179
+ c.style.cssText = 'position:fixed;left:-99999px;top:-99999px;opacity:0;pointer-events:none';
180
+ // Wrap in <table><tbody> so the browser's HTML parser keeps <tr>/<td> intact.
181
+ c.innerHTML = `<table><tbody>${html}</tbody></table>`;
182
+ document.body.appendChild(c);
183
+ }, [snapshot.rowHTML, snapId]);
184
+ }
185
+ try {
186
+ const result = {};
187
+ for (const [col, idx] of columnsToProcess) {
188
+ const mapper = (_b = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[col]) === null || _b === void 0 ? void 0 : _b.read;
189
+ if (mapper) {
190
+ try {
191
+ result[col] = await mapper(snapCell(idx), { row: env.row, columnName: col, columnIndex: idx, getCell });
192
+ }
193
+ catch (err) {
194
+ const wrapped = new Error(`[SmartTable] toJSON({ atomic: true }): column override for "${col}" failed — ${err.message}`);
195
+ wrapped.cause = err;
196
+ throw wrapped;
197
+ }
198
+ }
199
+ else if (idx < snapshot.cells.length) {
200
+ result[col] = snapshot.cells[idx].text;
201
+ }
202
+ }
203
+ await computeSyntheticColumns(env, result, columns);
204
+ return result;
205
+ }
206
+ finally {
207
+ if (hasAnyOverride)
208
+ await page.evaluate(id => { var _a; return (_a = document.getElementById(id)) === null || _a === void 0 ? void 0 : _a.remove(); }, snapId);
209
+ }
210
+ }
211
+ // ─── Live read ───────────────────────────────────────────────────────────────
212
+ /**
213
+ * #366 / #392: on virtualized tables the row's DOM node can recycle between the
214
+ * per-column awaits of a live read. When a self-healing locator is in use (the row locator was
215
+ * rebuilt with a CSS selector, e.g. `[data-rowindex="47"]`), the locator automatically
216
+ * re-queries the DOM — no rescan needed. For plain-number strategies (JS-only, no
217
+ * selector), fall back to rescan-then-scrollToRow.
218
+ *
219
+ * Returns `resolveStableRow` (call before each column) and `current` (last known-good row).
220
+ */
221
+ function createRowPinner(env) {
222
+ const { config, rowLocator, rowIndex } = env;
223
+ const resolveRI = config.strategies.resolveRowIndex;
224
+ const canPin = !!resolveRI && typeof rowIndex === 'number' && env.row._selfHealing !== true;
225
+ let lastGood = rowLocator;
226
+ const resolveStableRow = async () => {
227
+ // Self-healing locator: always re-queries the DOM by attribute — no rescan needed.
228
+ if (!canPin)
229
+ return rowLocator;
230
+ const result = await resolveRI(lastGood);
231
+ if (result !== undefined && (0, rowResolution_1.normalizeRowIndexResult)(result).index === rowIndex)
232
+ return lastGood; // still correct
233
+ // Drifted (recycled). Re-locate the row carrying the expected logical index.
234
+ lastGood = await relocateRecycledRow(env, rowIndex);
235
+ return lastGood;
236
+ };
237
+ return { resolveStableRow, current: () => lastGood };
238
+ }
239
+ /** Mounted row whose logical index is `rowIndex` (strict: resolveRowIndex errors propagate). */
240
+ async function rescanStrict(env, rowIndex) {
241
+ const resolveRI = env.config.strategies.resolveRowIndex;
242
+ const rows = await env.resolve(env.config.rowSelector, env.rootLocator).all();
243
+ for (const r of rows) {
244
+ const rResult = await resolveRI(r);
245
+ if (rResult !== undefined && (0, rowResolution_1.normalizeRowIndexResult)(rResult).index === rowIndex)
246
+ return r;
247
+ }
248
+ return null;
249
+ }
250
+ /** Live-read recovery: rescan, then (outside batches) scrollToRow and rescan once more. */
251
+ async function relocateRecycledRow(env, rowIndex) {
252
+ var _a;
253
+ const { config, rootLocator, resolve } = env;
254
+ const inBatch = isBatchRow(env.row);
255
+ let recovered = await rescanStrict(env, rowIndex);
256
+ const scrollToRow = (_a = config.strategies.viewport) === null || _a === void 0 ? void 0 : _a.scrollToRow;
257
+ if (!recovered && !inBatch && scrollToRow) {
258
+ await scrollToRow({ root: rootLocator, config, page: rootLocator.page(), resolve }, rowIndex);
259
+ recovered = await rescanStrict(env, rowIndex);
260
+ }
261
+ if (recovered)
262
+ return recovered;
263
+ throw new Error(`[SmartTable] toJSON: row ${rowIndex} recycled out of the DOM mid-read and could not be recovered — ` +
264
+ `the returned object would mix fields from different rows. ` +
265
+ recycledRowHint(inBatch, `(During a map/forEach batch, scroll-back recovery is disabled to avoid disrupting sibling rows; the row must stay mounted for the read.)`, `Ensure a viewport.scrollToRow can restore the row.`));
266
+ }
267
+ function cellIn(env, row, colName, colIdx) {
268
+ const { config, rootLocator } = env;
269
+ if (config.strategies.getCellLocator) {
270
+ return config.strategies.getCellLocator({
271
+ row, root: rootLocator, columnName: colName,
272
+ columnIndex: colIdx, rowIndex: env.rowIndex, page: rootLocator.page(), config,
273
+ });
274
+ }
275
+ return env.resolve(config.cellSelector, row).nth(colIdx);
276
+ }
277
+ /** undefined = unset (read as-is immediately); 0 = no wait (immediate check); >0 = wait up to N ms */
278
+ function cellLoadingTimeoutOf(config) {
279
+ var _a;
280
+ const raw = (_a = config.strategies.loading) === null || _a === void 0 ? void 0 : _a.cellLoadingTimeout;
281
+ return raw !== undefined && Number.isFinite(raw) && raw >= 0 ? raw : undefined;
282
+ }
283
+ /** Polls `check` every `intervalMs` until it returns true or `deadline` passes. */
284
+ async function pollUntil(page, deadline, intervalMs, check, initial) {
285
+ let done = initial;
286
+ while (!done && Date.now() < deadline) {
287
+ await page.waitForTimeout(Math.min(intervalMs, deadline - Date.now()));
288
+ if (Date.now() >= deadline)
289
+ break;
290
+ done = await check();
291
+ }
292
+ return done;
293
+ }
294
+ /**
295
+ * First-paint race: the cell node can attach a tick before the loading
296
+ * indicator mounts. A single false-negative then reads "" and skips
297
+ * onCellLoadingTimeout. Only grace when the cell still looks empty.
298
+ */
299
+ async function graceForLoadingIndicator(page, targetCell, isLoading, deadline) {
300
+ const peek = await (0, deadline_1.withinDeadline)(() => targetCell.evaluate((el) => (el.textContent || '').trim()).catch(() => ''), deadline, null);
301
+ if (peek === null)
302
+ return true; // Deadline expired: apply the cell loading timeout policy.
303
+ if (peek !== '')
304
+ return false;
305
+ return pollUntil(page, Math.min(Date.now() + 250, deadline), 25, isLoading, false);
306
+ }
307
+ /** What to do when a cell is still loading after `cellLoadingTimeout`. */
308
+ async function onStillLoading(env, targetCell, col, timeout) {
309
+ var _a;
310
+ var _b;
311
+ const { config, row: smart } = env;
312
+ const onCellLoadingTimeout = (_b = (_a = config.strategies.loading) === null || _a === void 0 ? void 0 : _a.onCellLoadingTimeout) !== null && _b !== void 0 ? _b : 'read-as-is';
313
+ (0, debugUtils_1.logDebug)(config, 'verbose', `toJSON: cell "${col}" — still loading after ${timeout}ms, action: ${typeof onCellLoadingTimeout === 'function' ? 'callback' : onCellLoadingTimeout}`);
314
+ if (typeof onCellLoadingTimeout === 'function') {
315
+ return { read: false, value: await onCellLoadingTimeout(targetCell, col, smart) };
316
+ }
317
+ if (onCellLoadingTimeout === 'skip')
318
+ return { read: false, value: '' };
319
+ if (onCellLoadingTimeout === 'throw') {
320
+ throw new Error(`[SmartTable] Cell "${col}" (row ${env.rowIndex}) did not finish loading within ${timeout}ms`);
321
+ }
322
+ // 'read-as-is': fall through to normal read
323
+ return { read: true };
324
+ }
325
+ /**
326
+ * Applies `loading.isCellLoading` / `cellLoadingTimeout` / `onCellLoadingTimeout` to one cell.
327
+ * Returns `{ read: true }` to read the cell normally, or the value to use instead.
328
+ */
329
+ async function waitForCellLoaded(env, targetCell, col) {
330
+ var _a;
331
+ const { config, row: smart } = env;
332
+ const isCellLoading = (_a = config.strategies.loading) === null || _a === void 0 ? void 0 : _a.isCellLoading;
333
+ if (!isCellLoading)
334
+ return { read: true };
335
+ const page = env.rootLocator.page();
336
+ const timeout = cellLoadingTimeoutOf(config);
337
+ const deadline = Date.now() + (timeout !== null && timeout !== void 0 ? timeout : Infinity);
338
+ const isLoading = () => (0, deadline_1.withinDeadline)(() => isCellLoading(targetCell, col, smart), deadline, true);
339
+ let loading = await isLoading();
340
+ if (!loading && timeout !== undefined && timeout > 0) {
341
+ loading = await graceForLoadingIndicator(page, targetCell, isLoading, deadline);
342
+ }
343
+ if (!loading || timeout === undefined)
344
+ return { read: true };
345
+ (0, debugUtils_1.logDebug)(config, 'verbose', `toJSON: cell "${col}" — waiting up to ${timeout}ms`);
346
+ const isLoaded = async () => !(await isLoading());
347
+ const initial = (timeout === 0 || Date.now() < deadline) && await isLoaded();
348
+ const cellResolved = await pollUntil(page, deadline, 100, isLoaded, initial);
349
+ return cellResolved ? { read: true } : onStillLoading(env, targetCell, col, timeout);
350
+ }
351
+ /**
352
+ * Default `toJSON()`: reads each column from the live DOM, navigating to it first
353
+ * (keeps synchronized batches in lock-step) and re-pinning the row before each read.
354
+ */
355
+ async function readRowLive(env, columns) {
356
+ var _a, _b, _c;
357
+ env = await resolveSnapshotIdentity(env);
358
+ const { config, map, rootLocator, rowIndex } = env;
359
+ const result = {};
360
+ const page = rootLocator.page();
361
+ const getHeaderCell = headerCellGetter(env);
362
+ const pinner = createRowPinner(env);
363
+ // getCell for column overrides: a live cell Locator on the last-known-good row,
364
+ // as fresh as possible, but raw (not override-processed).
365
+ const getCellLive = (colName) => {
366
+ const colIdx = map.get(colName);
367
+ if (colIdx === undefined)
368
+ throw new Error(`[SmartTable] getCell: column "${colName}" not found`);
369
+ return cellIn(env, pinner.current(), colName, colIdx);
370
+ };
371
+ for (const [col, idx] of map.entries()) {
372
+ if (columns && !columns.includes(col))
373
+ continue;
374
+ // Re-pin to the correct logical row before reading this column (#366).
375
+ const stableRow = await pinner.resolveStableRow();
376
+ const mapper = (_b = (_a = config.columnOverrides) === null || _a === void 0 ? void 0 : _a[col]) === null || _b === void 0 ? void 0 : _b.read;
377
+ const cell = cellIn(env, stableRow, col, idx);
378
+ // Always navigate to keep Lock-Step synchronization even if the cell is
379
+ // already present (navigateToCell handles the barrier check-in).
380
+ const navigatedCell = await (0, cellNavigation_1.navigateToCell)({
381
+ config,
382
+ rootLocator,
383
+ page,
384
+ resolve: env.resolve,
385
+ getHeaders: (_c = env.table) === null || _c === void 0 ? void 0 : _c.getHeaders,
386
+ column: col,
387
+ index: idx,
388
+ rowLocator: stableRow,
389
+ rowIndex,
390
+ renderWindowPosition: env.renderWindowPosition,
391
+ barrier: env.row._barrier,
392
+ allowMissingCell: !!mapper,
393
+ });
394
+ const targetCell = navigatedCell !== null && navigatedCell !== void 0 ? navigatedCell : cell;
395
+ // Fires for BOTH columnOverrides.read and the default innerText path.
396
+ if (config.strategies.beforeCellRead) {
397
+ await config.strategies.beforeCellRead({
398
+ cell: targetCell,
399
+ columnName: col,
400
+ columnIndex: idx,
401
+ row: stableRow,
402
+ page,
403
+ root: rootLocator,
404
+ getHeaderCell,
405
+ });
406
+ }
407
+ const loadOutcome = await waitForCellLoaded(env, targetCell, col);
408
+ if (!loadOutcome.read) {
409
+ result[col] = loadOutcome.value;
410
+ continue;
411
+ }
412
+ if (mapper) {
413
+ result[col] = await mapper(targetCell, { row: env.row, columnName: col, columnIndex: idx, getCell: getCellLive });
414
+ }
415
+ else {
416
+ const text = await targetCell.innerText();
417
+ result[col] = (text || '').trim();
418
+ }
419
+ }
420
+ await computeSyntheticColumns(env, result, columns);
421
+ return result;
422
+ }
@@ -21,10 +21,10 @@ import type { FinalTableConfig } from '../types';
21
21
  * | findRow | yes | options.useBulkPagination | yes | DOM/post filters only |
22
22
  * | countRows | yes | no (single-step) | yes | count-only / post filters |
23
23
  * | async iterator | yes | no | via runMap path | same as map |
24
- * | findRowByIndex | no* | no | n/a | scroll/search, not a full scan |
24
+ * | findRowByIndex | no | no | no | stops once the target index is mounted* |
25
25
  * | bringIntoView | n/a | path planner | n/a | not a page scan |
26
26
  *
27
- * \* findRowByIndex stops when the target index is mounted; it does not collect rows.
27
+ * \* findRowByIndex only falls back to scanPages after the mounted / viewport.scrollToRow paths miss.
28
28
  */
29
29
  export type ScanDecision = 'continue' | 'stop';
30
30
  export interface ScanPageContext {
@@ -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;