Strategies
All strategy options live under config.strategies. Every strategy is optional and additive — PST uses built-in defaults for anything not configured.
import { useTable, Strategies } from '@rickcedwhat/playwright-smart-table';
const { Pagination, Stabilization } = Strategies;
const table = await useTable(locator, {
strategies: {
pagination: Pagination.infiniteScroll({ ... }),
loading: { isCellLoading: async (cell) => ... },
},
}).init();Pagination
pagination
Signature
pagination?: PaginationStrategyControls how PST advances through pages during findRow, findRows, forEach, map, and filter.
Built-in factories via Strategies.Pagination:
Pagination.click
Advances by clicking a "Next page" button.
pagination: Strategies.Pagination.click({
nextButton: page.locator('[aria-label="Next page"]'),
prevButton: page.locator('[aria-label="Previous page"]'),
})Pagination.infiniteScroll
Advances by scrolling the table container.
pagination: Strategies.Pagination.infiniteScroll({
action: 'js-scroll', // 'scroll' | 'js-scroll'
scrollTarget: (root) => root,
scrollAmount: 300,
stabilization: Strategies.Stabilization.contentChanged({ scope: 'all', timeout: 3000 }),
})Loading
loading
Signature
loading?: LoadingStrategyStrategies for detecting loading states at the table, row, and cell level.
strategies: {
loading: {
isTableLoading: async (context) =>
(await context.root.locator('.spinner').count()) > 0,
isRowLoading: async (row) =>
(await row.locator('[aria-busy="true"]').count()) > 0,
isCellLoading: async (cell) =>
(await cell.locator('.skeleton').count()) > 0,
cellLoadingTimeout: 10_000,
onCellLoadingTimeout: 'read-as-is', // 'skip' | 'read-as-is' | 'throw'
},
}Timeouts:
loadingTimeout— max ms to wait whileisTableLoadingreturnstrue(before counting, finding, and between pages). Default10000. The wait gives up and continues when the budget runs out — it never hangs.sortStabilizationTimeout— overridesloadingTimeoutfor the wait aftersorting.apply()only.rowLoadingTimeout/onRowLoadingTimeout— how long to wait for a loading row, then'skip'·'read-as-is'·'throw'.cellLoadingTimeout/onCellLoadingTimeout— same for cells, or pass a callback that returns the value to use.
loading: {
isTableLoading: async ({ root }) => root.locator('.spinner').isVisible(),
loadingTimeout: 30_000, // slow backend
}Built-in presets via Strategies.Loading:
Strategies.Loading.Table.never— table is never loadingStrategies.Loading.Row.never— rows are never loading
Header
header
Signature
header?: HeaderStrategyCustom logic for discovering column names. Use when headers require scrolling or special parsing.
Built-in factories via Strategies.Header:
Strategies.Header.visible— reads headers currently in the DOMStrategies.Header.horizontalScroll— starts at the left edge and scans right to discover virtualized headers in column order, then returns to the left edge (requires{ selector }for the scroll container; omitting it returns visible headers only). Uses logical order when all headers havearia-colindex, otherwise content-space horizontal positions for the entire result; custom layouts without stable header positions should supply a custom header strategy. Duplicate labels are preserved for mapper validation orheaderTransformer.
// Discover virtualized headers by scrolling a known container
header: Strategies.Header.horizontalScroll({ selector: '.dvn-scroller' })// Custom header strategy
header: async ({ root, page }) => {
const headers = [];
// ... scroll and collect header text ...
return headers; // string[]
}Viewport
viewport
Signature
viewport?: ViewportStrategyOracle strategies for 2D virtualized tables (e.g., react-data-grid, AG Grid) where both rows and columns are recycled as the user scrolls. When present, PST uses these to detect and recover from the 2D scroll hazard.
viewport: {
getVisibleRowRange: async ({ root }) =>
root.evaluate(el => {
const rows = [...el.querySelectorAll('[aria-rowindex]')];
const idxs = rows.map(r => Number(r.getAttribute('aria-rowindex')) - 1);
return idxs.length
? { first: Math.min(...idxs), last: Math.max(...idxs) }
: { first: 0, last: 0 };
}),
getVisibleColumnRange: async ({ root }) =>
root.evaluate(el => {
const cells = [...el.querySelectorAll('[aria-colindex]')];
const idxs = cells.map(c => Number(c.getAttribute('aria-colindex')) - 1);
return idxs.length
? { first: Math.min(...idxs), last: Math.max(...idxs) }
: { first: 0, last: 0 };
}),
scrollToRow: async ({ root }, rowIndex) =>
root.evaluate((el, idx) => {
const prevScrollLeft = el.scrollLeft;
const row = el.querySelector(`[aria-rowindex="${idx + 1}"]`);
if (row) {
el.scrollTop = Math.max(0, parseInt(row.style.top || '0') - el.clientHeight / 3);
} else {
el.scrollTop = Math.max(0, idx * 35 - el.clientHeight / 3);
}
el.scrollLeft = prevScrollLeft; // preserve horizontal position
return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
}, rowIndex),
scrollToColumn: async ({ root }, colIndex) =>
root.evaluate((el, idx) => {
const prevScrollTop = el.scrollTop;
const header = el.querySelector(`[aria-colindex="${idx + 1}"]`);
if (header) el.scrollLeft = header.offsetLeft;
el.scrollTop = prevScrollTop; // preserve vertical position
return new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));
}, colIndex),
}TIP
After scrolling one axis in a 2D virtualized grid, the other axis is often reset by the grid's React re-render. Save and restore the other axis's scroll position inside the same evaluate() call, then wait two requestAnimationFrame ticks for React to commit.
Sorting
sorting
Signature
sorting?: SortingStrategyImplements column sort apply/detect for table.sorting.apply() and table.sorting.getState().
sorting: {
apply: async ({ root, page }, columnName, direction) => {
await root.locator(`[data-col="${columnName}"] .sort-btn`).click();
},
getState: async ({ root }, columnName) => {
const attr = await root.locator(`[data-col="${columnName}"]`).getAttribute('aria-sort');
return attr === 'ascending' ? 'asc' : attr === 'descending' ? 'desc' : 'none';
},
}Fill
fill
Signature
fill?: FillStrategyGlobal fill strategy for smartFill. Applied to all columns unless overridden by columnOverrides.write.
Built-in via Strategies.Fill:
Strategies.Fill.default— auto-detects input type (text, select, checkbox, contenteditable)
fill: Strategies.Fill.defaultDedupe
dedupe
Signature
dedupe?: DedupeStrategyPrevents a record from being yielded twice during a scan. Keys must identify the record across scrolling and pagination, rather than its mounted slot or page-local index.
// Dedupe by a unique, stable record ID
dedupe: async (row) => row.getCell('ID').innerText()Avoid viewport-position keys
Strategies.Dedupe.byTopPosition() is deprecated. It groups rows by their rounded screen Y coordinate, not by record identity. It only works when each position represents the same record throughout a scan. Scrolling and pagination invalidate that assumption: Alice and Bob can occupy the same slot, causing Bob to be dropped. The helper retains its existing behavior for compatibility.
An index attribute is suitable only if it identifies the record across the whole scan. If no stable key exists, omit dedupe and rely on built-in DOM tracking; this cannot guarantee record-level uniqueness across replacement nodes. Do not substitute random keys or screen coordinates.
Row Index
resolveRowIndex
Signature
resolveRowIndex?: (row: Locator) => Promise<RowIndexResult | undefined>Converts a DOM row into its logical (data-model) index. Used by findRow / findRows / iteration for rowIndex, by bringIntoView for virtual-scroll positioning, and required by findRowByIndex.
Return undefined to fall back to DOM position.
Return { index, selector } instead of a plain number when the index lives in a DOM attribute. The library then builds a self-healing row locator from selector that re-queries the DOM on every action, so getCell, smartFill, and toJSON keep pointing at the right row even after the virtualizer recycles DOM nodes.
// MUI DataGrid: self-healing via data-rowindex
resolveRowIndex: async (row) => {
const v = await row.getAttribute('data-rowindex').catch(() => null);
if (!v?.trim() || !Number.isFinite(Number(v))) return undefined;
return { index: Number(v), selector: `[data-rowindex="${v}"]` };
}Content Ready
contentReady
Signature
contentReady?: ContentReadyStrategyWaits until a row's content has stabilized before toJSON({ atomic: true }) snapshots it. Needed for recycling virtualizers (react-window, react-virtuoso with React concurrent mode) where the row moves into position before its cells re-render — without it, the snapshot can capture the previous row's text.
strategies: {
contentReady: Strategies.ContentReady.textStable(),
}Cell Locator
getCellLocator
Signature
getCellLocator?: GetCellLocatorFnCustom function for resolving a cell locator from a row and column index. Use for grids where the default cellSelector + nth-child approach doesn't match DOM structure.
// react-data-grid: use aria-colindex
getCellLocator: ({ row, columnIndex }) =>
row.locator(`[role="gridcell"][aria-colindex="${columnIndex + 1}"]`)Before Cell Read
beforeCellRead
Signature
beforeCellRead?: BeforeCellReadFnHook called before each cell value is read in toJSON and columnOverrides.read. Fires for both default innerText extraction and custom read mappers.
// Scroll an off-screen column into view before reading
beforeCellRead: async ({ root, columnName, columnIndex }) => {
await root.evaluate((el, idx) => { el.scrollLeft = idx * 120; }, columnIndex);
}Navigation
navigation
Signature
navigation?: NavigationPrimitivesPrimitive navigation functions used internally for keyboard-based cell navigation (goUp, goDown, goLeft, goRight, goHome).
Filter
filter
Signature
filter?: FilterStrategyPluggable row filter strategy. When present, FilterEngine delegates filter application to this strategy instead of using the default column-value comparison.
Stabilization
Used inside pagination strategies to detect when a page transition has settled.
Built-in factories via Strategies.Stabilization:
// Wait until table content stops changing
Strategies.Stabilization.contentChanged({ scope: 'all', timeout: 3000 })
// Wait a fixed delay
Strategies.Stabilization.timeout(500)