From 5fcbd86b4a197e3dda45673202e9fede77a4975b Mon Sep 17 00:00:00 2001 From: David Matejka Date: Fri, 25 Sep 2026 18:09:15 +0200 Subject: [PATCH 1/4] test: failing repro for page index kept beyond the last page after the total shrinks Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TPdGxo89UiPkZHxreAxvFD --- .../pagingPageIndexOutOfRange.test.tsx | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 tests/react/dataview/pagingPageIndexOutOfRange.test.tsx diff --git a/tests/react/dataview/pagingPageIndexOutOfRange.test.tsx b/tests/react/dataview/pagingPageIndexOutOfRange.test.tsx new file mode 100644 index 0000000..9aebf29 --- /dev/null +++ b/tests/react/dataview/pagingPageIndexOutOfRange.test.tsx @@ -0,0 +1,52 @@ +// Regression test for +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { renderHook, cleanup, act } from '@testing-library/react' +import { usePagingState, type StateStorage } from '@contember/bindx-dataview' + +afterEach(() => { + cleanup() +}) + +function createStorageWithPageIndex(pageIndex: number): StateStorage { + return { + get: (key: string): unknown => (key.endsWith('pageIndex') ? pageIndex : undefined), + set: (): void => {}, + remove: (): void => {}, + } +} + +describe('usePagingState — page index beyond the last page', () => { + test('should move back into range when the total count shrinks below the current page', () => { + const { result } = renderHook(() => usePagingState({ initialItemsPerPage: 50 })) + + act(() => { + result.current.setTotalCount(800) + }) + act(() => { + result.current.goTo(14) + }) + // A narrower filter: the count query now reports 33 rows, i.e. one page. + act(() => { + result.current.setTotalCount(33) + }) + + expect(result.current.info.totalPages).toBe(1) + expect(result.current.state.pageIndex).toBeLessThan(1) + expect(result.current.queryOffset).toBe(0) + }) + + test('should not query past the last page when a stored page index exceeds the total', () => { + const { result } = renderHook(() => + usePagingState({ initialItemsPerPage: 50, currentPageStateStorage: createStorageWithPageIndex(14) }), + ) + + act(() => { + result.current.setTotalCount(33) + }) + + expect(result.current.info.totalPages).toBe(1) + expect(result.current.state.pageIndex).toBeLessThan(1) + expect(result.current.queryOffset).toBe(0) + }) +}) From c829e10b2ba59e0939383bb417f21348eb2b7204 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 28 Sep 2026 11:13:43 +0200 Subject: [PATCH 2/4] fix(bindx-dataview): keep the page index within the known page count usePagingState stored the requested page index and derived the query offset from it without looking at the total. When the total shrank (a narrower filter) or a stored index outlived its result set, the grid queried an offset past the last row and rendered an empty page under a non-zero count ("page 15 of 1"). The effective page index is now the requested one clamped to [0, totalPages - 1] once the total is known, so state, queryOffset, hasNext and hasPrevious agree on the first render after the count lands. An effect writes the clamped value back so the stored index and the relative navigation (next/previous) continue from the page on screen. While the total is unknown the requested index is kept as is. Closes #127 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5 --- .../bindx-dataview/src/useDataViewState.ts | 22 ++++-- .../pagingPageIndexOutOfRange.test.tsx | 77 +++++++++++++++++-- 2 files changed, 86 insertions(+), 13 deletions(-) diff --git a/packages/bindx-dataview/src/useDataViewState.ts b/packages/bindx-dataview/src/useDataViewState.ts index 10a0b16..2b25afc 100644 --- a/packages/bindx-dataview/src/useDataViewState.ts +++ b/packages/bindx-dataview/src/useDataViewState.ts @@ -5,7 +5,7 @@ * for persisting state across navigations. */ -import { useState, useCallback, useMemo } from 'react' +import { useState, useCallback, useEffect, useMemo } from 'react' import type { FilterHandler, FilterArtifact, @@ -304,7 +304,7 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul storageKey = 'dataview', } = options - const [pageIndex, setPageIndex] = useStoredState( + const [requestedPageIndex, setPageIndex] = useStoredState( currentPageStateStorage, [storageKey, 'pageIndex'], (stored) => stored ?? 0, @@ -316,11 +316,6 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul (stored) => stored ?? initialItemsPerPage, ) - const state = useMemo((): PagingState => ({ - pageIndex, - itemsPerPage: itemsPerPage ?? 0, - }), [pageIndex, itemsPerPage]) - const [totalCount, setTotalCount] = useState(null) const [refreshCounter, setRefreshCounter] = useState(0) @@ -329,6 +324,19 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul return Math.max(1, Math.ceil(totalCount / itemsPerPage)) }, [totalCount, itemsPerPage]) + // A requested page past the end (the total shrank, or a stored index outlived its result set) + // reads as the last page, so the query offset never points past the rows. + const pageIndex = totalPages === null ? requestedPageIndex : Math.min(requestedPageIndex, totalPages - 1) + + useEffect(() => { + if (pageIndex !== requestedPageIndex) setPageIndex(pageIndex) + }, [pageIndex, requestedPageIndex, setPageIndex]) + + const state = useMemo((): PagingState => ({ + pageIndex, + itemsPerPage: itemsPerPage ?? 0, + }), [pageIndex, itemsPerPage]) + const info: PagingInfo = useMemo(() => ({ totalCount, totalPages, diff --git a/tests/react/dataview/pagingPageIndexOutOfRange.test.tsx b/tests/react/dataview/pagingPageIndexOutOfRange.test.tsx index 9aebf29..fbf08e4 100644 --- a/tests/react/dataview/pagingPageIndexOutOfRange.test.tsx +++ b/tests/react/dataview/pagingPageIndexOutOfRange.test.tsx @@ -1,4 +1,4 @@ -// Regression test for +// Regression test for https://github.com/contember/bindx/issues/127 import '../../setup' import { describe, test, expect, afterEach } from 'bun:test' import { renderHook, cleanup, act } from '@testing-library/react' @@ -8,11 +8,23 @@ afterEach(() => { cleanup() }) -function createStorageWithPageIndex(pageIndex: number): StateStorage { +/** Serializes like the web storages do, starting with a page index stored by an earlier visit. */ +function createStorageWithPageIndex(pageIndex: number): StateStorage & { readonly written: Map } { + const serialized = new Map([['dataview:pageIndex', JSON.stringify(pageIndex)]]) + const written = new Map() return { - get: (key: string): unknown => (key.endsWith('pageIndex') ? pageIndex : undefined), - set: (): void => {}, - remove: (): void => {}, + written, + get: (key: string): T | undefined => { + const raw = serialized.get(key) + return raw === undefined ? undefined : JSON.parse(raw) + }, + set: (key: string, value: unknown): void => { + serialized.set(key, JSON.stringify(value)) + written.set(key, value) + }, + remove: (key: string): void => { + serialized.delete(key) + }, } } @@ -37,8 +49,9 @@ describe('usePagingState — page index beyond the last page', () => { }) test('should not query past the last page when a stored page index exceeds the total', () => { + const storage = createStorageWithPageIndex(14) const { result } = renderHook(() => - usePagingState({ initialItemsPerPage: 50, currentPageStateStorage: createStorageWithPageIndex(14) }), + usePagingState({ initialItemsPerPage: 50, currentPageStateStorage: storage }), ) act(() => { @@ -48,5 +61,57 @@ describe('usePagingState — page index beyond the last page', () => { expect(result.current.info.totalPages).toBe(1) expect(result.current.state.pageIndex).toBeLessThan(1) expect(result.current.queryOffset).toBe(0) + expect(storage.written.get('dataview:pageIndex')).toBe(0) + }) + + test('should keep a stored page index while the total is unknown', () => { + const { result } = renderHook(() => + usePagingState({ initialItemsPerPage: 50, currentPageStateStorage: createStorageWithPageIndex(14) }), + ) + + expect(result.current.info.totalPages).toBeNull() + expect(result.current.state.pageIndex).toBe(14) + expect(result.current.queryOffset).toBe(700) + }) + + test('should move to the last existing page and navigate from there', () => { + const { result } = renderHook(() => usePagingState({ initialItemsPerPage: 50 })) + + act(() => { + result.current.setTotalCount(800) + }) + act(() => { + result.current.goTo(14) + }) + act(() => { + result.current.setTotalCount(120) + }) + + expect(result.current.state.pageIndex).toBe(2) + expect(result.current.queryOffset).toBe(100) + expect(result.current.hasNext).toBe(false) + + act(() => { + result.current.previous() + }) + + expect(result.current.state.pageIndex).toBe(1) + }) + + test('should clamp to the first page when the total drops to zero', () => { + const { result } = renderHook(() => usePagingState({ initialItemsPerPage: 50 })) + + act(() => { + result.current.setTotalCount(800) + }) + act(() => { + result.current.goTo(14) + }) + act(() => { + result.current.setTotalCount(0) + }) + + expect(result.current.state.pageIndex).toBe(0) + expect(result.current.queryOffset).toBe(0) }) }) From 3537d56463279eef00809639ff829275b583c564 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 28 Sep 2026 15:50:47 +0200 Subject: [PATCH 3/4] fix(bindx-dataview): infer a total only from the page of the current query Without a count (an adapter that implements no count query, or a failing one), the grids infer the total from a page shorter than the limit. On the render that changed the offset, that inference paired the new offset with the rows of the previous query. With the page-index clamp this cascaded: an empty page at offset 28 set a total of 26, the clamp moved to offset 26, the same stale rows set 24, and so on down to page 0 with a total of 0. Rows past the first page became unreachable. - useEntityList reports $isRefetching from the render that changes the options or the queryKey, not only after its data-loading effect has run, so a ready result never presents rows of an earlier query as current. - DataGrid, SelectDataView and HasManyDataGrid share usePagingTotalCount, which infers a total only from a page that answers the current query. HasManyDataGrid records the options its rows were loaded for. - An empty page past offset 0 implies its offset as an upper bound, so the clamp steps back one page per query and stops on the last page with rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5 --- packages/bindx-dataview/src/DataGrid.tsx | 16 +- .../bindx-dataview/src/HasManyDataGrid.tsx | 19 +- .../src/select/SelectDataView.tsx | 14 +- .../bindx-dataview/src/usePagingTotalCount.ts | 31 +++ .../bindx-react/src/hooks/useEntityList.ts | 30 ++- .../dataGridPageIndexOutOfRange.test.tsx | 195 ++++++++++++++++++ .../refetchingOnQueryChange.test.tsx | 57 +++++ 7 files changed, 323 insertions(+), 39 deletions(-) create mode 100644 packages/bindx-dataview/src/usePagingTotalCount.ts create mode 100644 tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx create mode 100644 tests/react/hooks/useEntityList/refetchingOnQueryChange.test.tsx diff --git a/packages/bindx-dataview/src/DataGrid.tsx b/packages/bindx-dataview/src/DataGrid.tsx index 4ec8839..fed9e12 100644 --- a/packages/bindx-dataview/src/DataGrid.tsx +++ b/packages/bindx-dataview/src/DataGrid.tsx @@ -24,6 +24,7 @@ import { useDataViewKey } from './DataViewKeyProvider.js' import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from './DataViewContext.js' import { useDataGridSetup, QUERY_FILTER_NAME } from './useDataGridSetup.js' import { useListFetchAllData } from './useListFetchAllData.js' +import { usePagingTotalCount } from './usePagingTotalCount.js' export { QUERY_FILTER_NAME } @@ -128,17 +129,10 @@ function DataGridImpl>({ } }, [result.$status]) - // Update total count from the count query. Falls back to the partial-page - // heuristic (last page returns fewer rows than the page size) when the count - // is not yet known — e.g. before it resolves or with adapters that don't - // implement count queries. - useEffect(() => { - if (totalCount !== null) { - setup.paging.setTotalCount(totalCount) - } else if (result.$status === 'ready' && setup.paging.queryLimit !== undefined && setup.paging.queryOffset !== undefined && itemCount < setup.paging.queryLimit) { - setup.paging.setTotalCount(setup.paging.queryOffset + itemCount) - } - }, [totalCount, result.$status, itemCount, setup.paging.queryLimit, setup.paging.queryOffset, setup.paging.setTotalCount]) + usePagingTotalCount(setup.paging, totalCount, { + isCurrent: result.$status === 'ready' && !result.$isRefetching, + itemCount, + }) // ---- Reload ---- const [, setReloadCounter] = useState(0) diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index 1227111..4e9d34c 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -42,6 +42,7 @@ import { useDataViewKey } from './DataViewKeyProvider.js' import { DataViewProvider, type DataViewContextValue, type DataViewFetchAllData, type DataViewLoaderState } from './DataViewContext.js' import { useDataGridSetup } from './useDataGridSetup.js' import { buildHasManyRelationQuery, extractHasManyRelationRows } from './hasManyRelationQuery.js' +import { usePagingTotalCount } from './usePagingTotalCount.js' // ============================================================================ // Props @@ -80,6 +81,8 @@ interface ListState { status: 'loading' | 'error' | 'ready' items: Array<{ id: string; data: object }> totalCount?: number + /** The query options the items were loaded for. */ + optionsKey?: string } const INITIAL_LIST_STATE: ListState = { status: 'loading', items: [] } @@ -185,7 +188,7 @@ function HasManyDataGridImpl({ const relation = extractHasManyRelationRows(result.data, { alias, fieldName }) if (!relation) { - setListState({ status: 'ready', items: [] }) + setListState({ status: 'ready', items: [], optionsKey }) return } @@ -196,7 +199,7 @@ function HasManyDataGridImpl({ } }) - setListState({ status: 'ready', items, totalCount: relation.totalCount }) + setListState({ status: 'ready', items, totalCount: relation.totalCount, optionsKey }) } catch (error) { if (abortController.signal.aborted) return setListState({ status: 'error', items: [] }) @@ -238,14 +241,10 @@ function HasManyDataGridImpl({ } }, [listState.status]) - // Update total count from paginateRelation - useEffect(() => { - if (listState.totalCount !== undefined) { - setup.paging.setTotalCount(listState.totalCount) - } else if (listState.status === 'ready' && setup.paging.queryLimit !== undefined && setup.paging.queryOffset !== undefined && itemCount < setup.paging.queryLimit) { - setup.paging.setTotalCount(setup.paging.queryOffset + itemCount) - } - }, [listState.status, listState.totalCount, itemCount, setup.paging.queryLimit, setup.paging.queryOffset, setup.paging.setTotalCount]) + usePagingTotalCount(setup.paging, listState.totalCount ?? null, { + isCurrent: listState.status === 'ready' && listState.optionsKey === optionsKey, + itemCount, + }) // ---- Reload ---- const [, setReloadCounter] = useState(0) diff --git a/packages/bindx-dataview/src/select/SelectDataView.tsx b/packages/bindx-dataview/src/select/SelectDataView.tsx index d81c401..8c70f13 100644 --- a/packages/bindx-dataview/src/select/SelectDataView.tsx +++ b/packages/bindx-dataview/src/select/SelectDataView.tsx @@ -46,6 +46,7 @@ import { useSelectOptions } from './selectContext.js' import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from '../DataViewContext.js' import { useFilteringState, useSortingState, usePagingState, useSelectionState } from '../useDataViewState.js' import { useListFetchAllData } from '../useListFetchAllData.js' +import { usePagingTotalCount } from '../usePagingTotalCount.js' export interface SelectDataViewProps { /** Children rendered inside the DataView context */ @@ -185,15 +186,10 @@ function SelectDataViewImpl({ } }, [result.$status]) - // Update total count from the count query, falling back to the partial-page - // heuristic until the count resolves. - useEffect(() => { - if (totalCount !== null) { - paging.setTotalCount(totalCount) - } else if (result.$status === 'ready' && paging.queryLimit !== undefined && paging.queryOffset !== undefined && itemCount < paging.queryLimit) { - paging.setTotalCount(paging.queryOffset + itemCount) - } - }, [totalCount, result.$status, itemCount, paging.queryLimit, paging.queryOffset, paging.setTotalCount]) + usePagingTotalCount(paging, totalCount, { + isCurrent: result.$status === 'ready' && !result.$isRefetching, + itemCount, + }) // ---- Reload ---- const [, setReloadCounter] = useState(0) diff --git a/packages/bindx-dataview/src/usePagingTotalCount.ts b/packages/bindx-dataview/src/usePagingTotalCount.ts new file mode 100644 index 0000000..d46f723 --- /dev/null +++ b/packages/bindx-dataview/src/usePagingTotalCount.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react' +import type { PagingStateResult } from './useDataViewState.js' + +export interface LoadedPage { + /** Whether the rows answer the current query: filter, order, limit and offset. */ + readonly isCurrent: boolean + readonly itemCount: number +} + +/** + * Feeds the paging state its total: the counted total when the data source reports one, + * otherwise the total implied by a page shorter than the limit. + * + * Only a page that answers the current query implies a total. An empty page past offset 0 + * implies its offset, an upper bound, so the clamp moves back one page to a page that may still hold rows. + */ +export function usePagingTotalCount(paging: PagingStateResult, countedTotal: number | null, page: LoadedPage): void { + const { setTotalCount, queryLimit, queryOffset } = paging + const { isCurrent, itemCount } = page + + useEffect(() => { + if (countedTotal !== null) { + setTotalCount(countedTotal) + return + } + if (!isCurrent || queryLimit === undefined || queryOffset === undefined || itemCount >= queryLimit) { + return + } + setTotalCount(queryOffset + itemCount) + }, [countedTotal, isCurrent, itemCount, queryLimit, queryOffset, setTotalCount]) +} diff --git a/packages/bindx-react/src/hooks/useEntityList.ts b/packages/bindx-react/src/hooks/useEntityList.ts index 7ec691f..e9409fc 100644 --- a/packages/bindx-react/src/hooks/useEntityList.ts +++ b/packages/bindx-react/src/hooks/useEntityList.ts @@ -58,8 +58,8 @@ export type ErrorEntityListResult = EntityListResultBase & { } /** - * `$isRefetching` is `true` while a background re-fetch is in flight - * (triggered by a `queryKey` change while ready data is already present). + * `$isRefetching` is `true` while the items answer an earlier query: from the render + * that changes the options or the `queryKey` until the re-fetch returns. * The accessor identity stays stable so the subtree does not unmount — * stale-while-revalidate semantics. */ @@ -217,16 +217,24 @@ export function useEntityList( return `${entityType}:${selectionContentKey}` }, [options.queryKey, selectionContentKey, entityType]) + const requestKey = useMemo( + () => JSON.stringify([effectiveQueryKey, optionsKey]), + [effectiveQueryKey, optionsKey], + ) + // --- List state tracking --- const listStateRef = useRef<{ status: 'loading' | 'error' | 'ready' items: Array<{ id: string; data: object }> error?: FieldError isRefetching: boolean + /** The request the items were loaded for. */ + requestKey: string | null }>({ status: 'loading', items: [], isRefetching: false, + requestKey: null, }) const versionRef = useRef(0) @@ -350,6 +358,9 @@ export function useEntityList( const state = listStateRef.current const version = versionRef.current const storeVersion = store.getVersion() + // Until the data-loading effect marks the refetch, the items of the previous request are + // already stale on the render that changed it. + const isRefetching = state.isRefetching || state.requestKey !== requestKey const cache = listCacheRef.current if ( @@ -357,7 +368,7 @@ export function useEntityList( cache.version === version && cache.storeVersion === storeVersion && cache.status === state.status && - cache.isRefetching === state.isRefetching && + cache.isRefetching === isRefetching && cache.accessorCache === itemAccessorCache ) { return cache.result @@ -382,7 +393,7 @@ export function useEntityList( result = { $status: 'ready', $isLoading: false, - $isRefetching: state.isRefetching, + $isRefetching: isRefetching, $isError: false, $error: null, $isDirty: false, @@ -398,13 +409,13 @@ export function useEntityList( version, storeVersion, status: state.status, - isRefetching: state.isRefetching, + isRefetching, accessorCache: itemAccessorCache, result, } return result - }, [store, itemAccessorCache, addItem, removeItem, moveItem, resolveItemId]) + }, [store, requestKey, itemAccessorCache, addItem, removeItem, moveItem, resolveItemId]) const isEqual = useCallback( (a: UseEntityListResult, b: UseEntityListResult): boolean => { @@ -429,7 +440,7 @@ export function useEntityList( if (prev.status === 'ready') { listStateRef.current = { ...prev, isRefetching: true } } else { - listStateRef.current = { status: 'loading', items: [], isRefetching: false } + listStateRef.current = { status: 'loading', items: [], isRefetching: false, requestKey: null } } versionRef.current++ store.notify() @@ -468,7 +479,7 @@ export function useEntityList( // Revalidation preserves local edits while advancing the server baseline. dispatcher.dispatch(refreshServerData(entityType, item.id, item.data)) } - listStateRef.current = { status: 'ready', items, isRefetching: false } + listStateRef.current = { status: 'ready', items, isRefetching: false, requestKey } versionRef.current++ store.notify() }) @@ -481,6 +492,7 @@ export function useEntityList( items: [], error: createLoadError(normalizedError), isRefetching: false, + requestKey, } versionRef.current++ store.notify() @@ -492,7 +504,7 @@ export function useEntityList( return () => { abortController.abort() } - }, [entityType, optionsKey, effectiveQueryKey, batcher, dispatcher, store, selectionMeta]) + }, [entityType, optionsKey, requestKey, batcher, dispatcher, store, selectionMeta]) return accessor } diff --git a/tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx b/tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx new file mode 100644 index 0000000..d09e866 --- /dev/null +++ b/tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx @@ -0,0 +1,195 @@ +// DataGrid-level coverage for https://github.com/contember/bindx/issues/127: a page index past the +// last page — restored from storage or left behind by a narrower filter — lands on the last page with rows. +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { render, waitFor, cleanup, act, fireEvent } from '@testing-library/react' +import React, { type ReactElement } from 'react' +import { BindxProvider, MockAdapter, defineSchema, scalar } from '@contember/bindx-react' +import type { BackendAdapter, Query, QueryResult, QueryOptions } from '@contember/bindx' +import { schema } from '../../shared/index.js' +import { DataGrid, DataGridTextColumn, type StateStorage } from '@contember/bindx-dataview' +import { TestTable, TestPagination, getByTestId, queryByTestId, getRowCount, getCellText } from './helpers.js' + +afterEach(() => { + cleanup() +}) + +interface Article { + id: string + title: string + published: boolean +} + +const localSchema = defineSchema<{ Article: Article }>({ + entities: { + Article: { + fields: { + id: scalar(), + title: scalar(), + published: scalar(), + }, + }, + }, +}) + +const PUBLISHED_ONLY = { published: { eq: true } } + +/** `rowCount` articles; the first three are published. */ +function createData(rowCount: number): Record>> { + const Article: Record> = {} + for (let i = 1; i <= rowCount; i++) { + const id = `a${String(i).padStart(2, '0')}` + Article[id] = { id, title: `Article ${i}`, published: i <= 3 } + } + return { Article } +} + +/** Answers count queries without a count, like an adapter that implements none. */ +class CountlessAdapter implements BackendAdapter { + constructor(private readonly inner: MockAdapter) {} + + async query(queries: readonly Query[], options?: QueryOptions): Promise { + const results = await this.inner.query(queries, options) + return results.map((result): QueryResult => result.type === 'count' ? { type: 'list', data: [] } : result) + } + + persist(...args: Parameters): ReturnType { + return this.inner.persist(...args) + } +} + +function createCountingAdapter(rowCount: number): BackendAdapter { + return new MockAdapter(createData(rowCount), { delay: 0 }) +} + +function createCountlessAdapter(rowCount: number): BackendAdapter { + return new CountlessAdapter(new MockAdapter(createData(rowCount), { delay: 0 })) +} + +/** Serializes like the web storages do, starting with a page index stored by an earlier visit. */ +function createStorageWithPageIndex(pageIndex: number): StateStorage & { readonly written: Map } { + const serialized = new Map([['dataview:pageIndex', JSON.stringify(pageIndex)]]) + const written = new Map() + return { + written, + get: (key: string): T | undefined => { + const raw = serialized.get(key) + return raw === undefined ? undefined : JSON.parse(raw) + }, + set: (key: string, value: unknown): void => { + serialized.set(key, JSON.stringify(value)) + written.set(key, value) + }, + remove: (key: string): void => { + serialized.delete(key) + }, + } +} + +interface GridProps { + adapter: BackendAdapter + storage?: StateStorage + filter?: Record +} + +function Grid({ adapter, storage, filter }: GridProps): ReactElement { + return ( + + + {it => ( + <> + + + + + )} + + + ) +} + +function pageInfo(container: Element): string | null { + return getByTestId(container, 'datagrid-pagination-info').textContent +} + +function totalInfo(container: Element): string | null | undefined { + return queryByTestId(container, 'datagrid-pagination-total')?.textContent +} + +function isDisabled(container: Element, testId: string): boolean { + const button = getByTestId(container, testId) + return button instanceof HTMLButtonElement && button.disabled +} + +async function click(container: Element, testId: string): Promise { + await act(async () => { + fireEvent.click(getByTestId(container, testId)) + }) +} + +/** Walks from the first page to the last and returns the titles of every row on the way. */ +async function readAllPages(container: Element): Promise { + await click(container, 'datagrid-pagination-first') + await waitFor(() => expect(pageInfo(container)).toStartWith('Page 1 ')) + + const titles: string[] = [] + for (let page = 1; ; page++) { + await waitFor(() => expect(pageInfo(container)).toStartWith(`Page ${page} `)) + await waitFor(() => expect(getRowCount(container)).toBeGreaterThan(0)) + for (let row = 0; row < getRowCount(container); row++) { + titles.push(getCellText(container, row, 'title')) + } + if (isDisabled(container, 'datagrid-pagination-next')) return titles + await click(container, 'datagrid-pagination-next') + } +} + +const ALL_SEVEN_TITLES = ['Article 1', 'Article 2', 'Article 3', 'Article 4', 'Article 5', 'Article 6', 'Article 7'] + +describe('DataGrid — page index beyond the last page', () => { + test('a stored page past the end lands on the last page when the adapter counts', async () => { + const storage = createStorageWithPageIndex(14) + const { container } = render() + + await waitFor(() => expect(pageInfo(container)).toBe('Page 4 of 4')) + await waitFor(() => expect(getRowCount(container)).toBe(1)) + expect(getCellText(container, 0, 'title')).toBe('Article 7') + expect(totalInfo(container)).toBe('7 total') + expect(storage.written.get('dataview:pageIndex')).toBe(3) + }) + + test('a stored page past the end lands on the last page when the adapter does not count', async () => { + const storage = createStorageWithPageIndex(14) + const { container } = render() + + await waitFor(() => expect(pageInfo(container)).toBe('Page 4 of 4')) + await waitFor(() => expect(getRowCount(container)).toBe(1)) + expect(getCellText(container, 0, 'title')).toBe('Article 7') + expect(totalInfo(container)).toBe('7 total') + expect(storage.written.get('dataview:pageIndex')).toBe(3) + + expect((await readAllPages(container)).sort()).toEqual(ALL_SEVEN_TITLES) + }) + + test('a narrower filter on the last page moves to the new last page when the adapter does not count', async () => { + const adapter = createCountlessAdapter(9) + const { container, rerender } = render() + + await waitFor(() => expect(getRowCount(container)).toBe(2)) + for (let page = 2; page <= 5; page++) { + await click(container, 'datagrid-pagination-next') + await waitFor(() => expect(pageInfo(container)).toStartWith(`Page ${page}`)) + } + await waitFor(() => expect(pageInfo(container)).toBe('Page 5 of 5')) + + rerender() + + await waitFor(() => expect(pageInfo(container)).toBe('Page 2 of 2')) + await waitFor(() => expect(getRowCount(container)).toBe(1)) + expect(getCellText(container, 0, 'title')).toBe('Article 3') + expect(totalInfo(container)).toBe('3 total') + expect(isDisabled(container, 'datagrid-pagination-next')).toBe(true) + + expect((await readAllPages(container)).sort()).toEqual(['Article 1', 'Article 2', 'Article 3']) + }) +}) diff --git a/tests/react/hooks/useEntityList/refetchingOnQueryChange.test.tsx b/tests/react/hooks/useEntityList/refetchingOnQueryChange.test.tsx new file mode 100644 index 0000000..b3ca386 --- /dev/null +++ b/tests/react/hooks/useEntityList/refetchingOnQueryChange.test.tsx @@ -0,0 +1,57 @@ +import '../../../setup' +import { afterEach, expect, test } from 'bun:test' +import { act, cleanup, render, waitFor } from '@testing-library/react' +import React, { useState } from 'react' +import { BindxProvider, MockAdapter, defineSchema, entityDef, scalar, useEntityList } from '@contember/bindx-react' + +afterEach(cleanup) + +interface Article { + id: string + title: string +} + +const schema = defineSchema<{ Article: Article }>({ + entities: { Article: { fields: { id: scalar(), title: scalar() } } }, +}) +const articleDef = entityDef
('Article') + +interface RenderObservation { + readonly offset: number + readonly isRefetching: boolean + readonly titles: readonly string[] +} + +test('items of the previous offset are reported as refetching from the render that changes the offset', async () => { + const rows = Object.fromEntries(['a', 'b', 'c', 'd'].map(id => [id, { id, title: `Title ${id}` }])) + const observations: RenderObservation[] = [] + let setOffset: (offset: number) => void = () => {} + + function List(): React.JSX.Element { + const [offset, setOffsetState] = useState(0) + setOffset = setOffsetState + const articles = useEntityList(articleDef, { limit: 2, offset, orderBy: [{ id: 'asc' }] }, a => a.id().title()) + if (articles.$status !== 'ready') return
Loading
+ const titles = articles.items.map(item => item.title.value ?? '') + observations.push({ offset, isRefetching: articles.$isRefetching, titles }) + return
{`${offset}: ${titles.join(', ')}`}
+ } + + const view = render( + + + , + ) + await waitFor(() => expect(view.getByText('0: Title a, Title b')).toBeDefined()) + + act(() => { + setOffset(2) + }) + await waitFor(() => expect(view.getByText('2: Title c, Title d')).toBeDefined()) + + const settledAtOffsetTwo = observations.filter(observation => observation.offset === 2 && !observation.isRefetching) + expect(settledAtOffsetTwo.length).toBeGreaterThan(0) + for (const observation of settledAtOffsetTwo) { + expect(observation.titles).toEqual(['Title c', 'Title d']) + } +}) From f354e71d5f9790fc38792430ab203a7fad6d2d40 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Mon, 28 Sep 2026 16:10:05 +0200 Subject: [PATCH 4/4] fix(bindx-dataview): bisect to the last page when the total is not counted Without a count, an empty page past offset 0 only bounds the total from above. Stepping back one page per query cost one round trip per page, so a stored page 500 took 500 queries. usePagingTotalCount now keeps the bounds the loaded pages prove (a full page raises the lower one, an empty page lowers the upper one) and probes the page holding the middle row, so the interval halves with every query. A short page, or bounds that meet, set the exact total. The bounds belong to one row set and restart when the filter changes. Also covers HasManyDataGrid with a relation that reports no total, and states in the useEntity doc when its $isRefetching turns on. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5 --- packages/bindx-dataview/src/DataGrid.tsx | 9 +- .../bindx-dataview/src/HasManyDataGrid.tsx | 12 +- .../src/select/SelectDataView.tsx | 9 +- .../bindx-dataview/src/usePagingTotalCount.ts | 85 +++++++++++--- packages/bindx-react/src/hooks/useEntity.ts | 6 +- .../dataGridPageIndexOutOfRange.test.tsx | 57 +++++++-- ...asManyDataGridPageIndexOutOfRange.test.tsx | 109 ++++++++++++++++++ 7 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 tests/react/dataview/hasManyDataGridPageIndexOutOfRange.test.tsx diff --git a/packages/bindx-dataview/src/DataGrid.tsx b/packages/bindx-dataview/src/DataGrid.tsx index fed9e12..4b5ba05 100644 --- a/packages/bindx-dataview/src/DataGrid.tsx +++ b/packages/bindx-dataview/src/DataGrid.tsx @@ -129,9 +129,12 @@ function DataGridImpl>({ } }, [result.$status]) - usePagingTotalCount(setup.paging, totalCount, { - isCurrent: result.$status === 'ready' && !result.$isRefetching, - itemCount, + const countKey = useMemo(() => JSON.stringify(setup.combinedFilter ?? {}), [setup.combinedFilter]) + usePagingTotalCount(setup.paging, { + countedTotal: totalCount, + countKey, + isPageCurrent: result.$status === 'ready' && !result.$isRefetching, + pageItemCount: itemCount, }) // ---- Reload ---- diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index 4e9d34c..54efff7 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -241,9 +241,15 @@ function HasManyDataGridImpl({ } }, [listState.status]) - usePagingTotalCount(setup.paging, listState.totalCount ?? null, { - isCurrent: listState.status === 'ready' && listState.optionsKey === optionsKey, - itemCount, + const countKey = useMemo( + () => JSON.stringify([parentEntityType, parentEntityId, fieldName, setup.combinedFilter ?? {}]), + [parentEntityType, parentEntityId, fieldName, setup.combinedFilter], + ) + usePagingTotalCount(setup.paging, { + countedTotal: listState.totalCount ?? null, + countKey, + isPageCurrent: listState.status === 'ready' && listState.optionsKey === optionsKey, + pageItemCount: itemCount, }) // ---- Reload ---- diff --git a/packages/bindx-dataview/src/select/SelectDataView.tsx b/packages/bindx-dataview/src/select/SelectDataView.tsx index 8c70f13..aa2a835 100644 --- a/packages/bindx-dataview/src/select/SelectDataView.tsx +++ b/packages/bindx-dataview/src/select/SelectDataView.tsx @@ -186,9 +186,12 @@ function SelectDataViewImpl({ } }, [result.$status]) - usePagingTotalCount(paging, totalCount, { - isCurrent: result.$status === 'ready' && !result.$isRefetching, - itemCount, + const countKey = useMemo(() => JSON.stringify(combinedFilter ?? {}), [combinedFilter]) + usePagingTotalCount(paging, { + countedTotal: totalCount, + countKey, + isPageCurrent: result.$status === 'ready' && !result.$isRefetching, + pageItemCount: itemCount, }) // ---- Reload ---- diff --git a/packages/bindx-dataview/src/usePagingTotalCount.ts b/packages/bindx-dataview/src/usePagingTotalCount.ts index d46f723..b716738 100644 --- a/packages/bindx-dataview/src/usePagingTotalCount.ts +++ b/packages/bindx-dataview/src/usePagingTotalCount.ts @@ -1,31 +1,90 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import type { PagingStateResult } from './useDataViewState.js' -export interface LoadedPage { - /** Whether the rows answer the current query: filter, order, limit and offset. */ - readonly isCurrent: boolean +export interface PagingTotalSource { + /** Total reported by the data source, or null when it reports none. */ + readonly countedTotal: number | null + /** Identifies the counted row set (the filter); bounds learned for another row set are dropped. */ + readonly countKey: string + /** Whether the loaded rows answer the current query: filter, order, limit and offset. */ + readonly isPageCurrent: boolean + readonly pageItemCount: number +} + +/** What the loaded pages prove about the total: `atLeast <= total <= atMost`. */ +interface TotalBounds { + readonly atLeast: number + readonly atMost: number | null +} + +interface LoadedPage { + readonly offset: number + readonly limit: number readonly itemCount: number } +type PageOutcome = + | { readonly type: 'total'; readonly totalCount: number } + | { readonly type: 'probe'; readonly bounds: TotalBounds; readonly pageIndex: number } + | { readonly type: 'unknown'; readonly bounds: TotalBounds } + +const NO_BOUNDS: TotalBounds = { atLeast: 0, atMost: null } + /** * Feeds the paging state its total: the counted total when the data source reports one, - * otherwise the total implied by a page shorter than the limit. + * otherwise the total the loaded pages prove. Only a page that answers the current query counts. * - * Only a page that answers the current query implies a total. An empty page past offset 0 - * implies its offset, an upper bound, so the clamp moves back one page to a page that may still hold rows. + * A page shorter than the limit ends the list. An empty page past offset 0 only bounds the total + * from above, so the grid bisects between the bounds, one page per query, until a page ends the list. */ -export function usePagingTotalCount(paging: PagingStateResult, countedTotal: number | null, page: LoadedPage): void { - const { setTotalCount, queryLimit, queryOffset } = paging - const { isCurrent, itemCount } = page +export function usePagingTotalCount(paging: PagingStateResult, source: PagingTotalSource): void { + const { setTotalCount, goTo, queryLimit, queryOffset } = paging + const { countedTotal, countKey, isPageCurrent, pageItemCount } = source + const boundsRef = useRef<{ countKey: string; bounds: TotalBounds }>({ countKey, bounds: NO_BOUNDS }) useEffect(() => { + if (boundsRef.current.countKey !== countKey || countedTotal !== null) { + boundsRef.current = { countKey, bounds: NO_BOUNDS } + } if (countedTotal !== null) { setTotalCount(countedTotal) return } - if (!isCurrent || queryLimit === undefined || queryOffset === undefined || itemCount >= queryLimit) { + if (!isPageCurrent || queryLimit === undefined || queryOffset === undefined) { return } - setTotalCount(queryOffset + itemCount) - }, [countedTotal, isCurrent, itemCount, queryLimit, queryOffset, setTotalCount]) + + const outcome = inferTotalFromPage(boundsRef.current.bounds, { offset: queryOffset, limit: queryLimit, itemCount: pageItemCount }) + if (outcome.type === 'total') { + boundsRef.current = { countKey, bounds: NO_BOUNDS } + setTotalCount(outcome.totalCount) + return + } + boundsRef.current = { countKey, bounds: outcome.bounds } + if (outcome.type === 'probe') { + goTo(outcome.pageIndex) + } + }, [countedTotal, countKey, isPageCurrent, pageItemCount, queryLimit, queryOffset, setTotalCount, goTo]) +} + +function inferTotalFromPage(bounds: TotalBounds, { offset, limit, itemCount }: LoadedPage): PageOutcome { + const isEmptyPastStart = itemCount === 0 && offset > 0 + if (itemCount < limit && !isEmptyPastStart) { + return { type: 'total', totalCount: offset + itemCount } + } + + const narrowed: TotalBounds = isEmptyPastStart + ? { atLeast: bounds.atLeast, atMost: Math.min(bounds.atMost ?? offset, offset) } + : { atLeast: Math.max(bounds.atLeast, offset + limit), atMost: bounds.atMost } + + if (narrowed.atMost === null) { + return { type: 'unknown', bounds: narrowed } + } + if (narrowed.atLeast >= narrowed.atMost) { + return { type: 'total', totalCount: narrowed.atMost } + } + // The page holding the middle row starts below `atMost` and ends above `atLeast`, + // so whatever it returns halves the interval. + const middleRow = Math.floor((narrowed.atLeast + narrowed.atMost) / 2) + return { type: 'probe', bounds: narrowed, pageIndex: Math.floor(middleRow / limit) } } diff --git a/packages/bindx-react/src/hooks/useEntity.ts b/packages/bindx-react/src/hooks/useEntity.ts index 97294e6..0e12393 100644 --- a/packages/bindx-react/src/hooks/useEntity.ts +++ b/packages/bindx-react/src/hooks/useEntity.ts @@ -93,8 +93,10 @@ export type NotFoundEntityResult = UseEntityResultBase & { /** * Ready state — full EntityAccessor with status metadata. * - * `$isRefetching` is `true` while a background re-fetch is in flight - * (triggered by a `queryKey` change while ready data is already present). + * `$isRefetching` is `true` while the data answers an earlier query: from the re-fetch + * that a `queryKey` change starts, while ready data is already present, until its result + * arrives. Unlike `useEntityList`, the flag turns on when the re-fetch starts, one render + * after the change, not on the render that changes the query. * The accessor identity and field values remain stable until the new data * arrives, so the subtree does not unmount — useful for stale-while-revalidate * indicators (subtle spinner, "stale" badge, etc.). diff --git a/tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx b/tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx index d09e866..447ab17 100644 --- a/tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx +++ b/tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx @@ -44,12 +44,21 @@ function createData(rowCount: number): Record { + for (const query of queries) { + if (query.type === 'list') this.listOffsets.push(query.offset) + } const results = await this.inner.query(queries, options) + if (this.answersCount) return results return results.map((result): QueryResult => result.type === 'count' ? { type: 'list', data: [] } : result) } @@ -58,12 +67,12 @@ class CountlessAdapter implements BackendAdapter { } } -function createCountingAdapter(rowCount: number): BackendAdapter { - return new MockAdapter(createData(rowCount), { delay: 0 }) +function createCountingAdapter(rowCount: number): RecordingAdapter { + return new RecordingAdapter(new MockAdapter(createData(rowCount), { delay: 0 }), true) } -function createCountlessAdapter(rowCount: number): BackendAdapter { - return new CountlessAdapter(new MockAdapter(createData(rowCount), { delay: 0 })) +function createCountlessAdapter(rowCount: number): RecordingAdapter { + return new RecordingAdapter(new MockAdapter(createData(rowCount), { delay: 0 }), false) } /** Serializes like the web storages do, starting with a page index stored by an earlier visit. */ @@ -148,29 +157,55 @@ const ALL_SEVEN_TITLES = ['Article 1', 'Article 2', 'Article 3', 'Article 4', 'A describe('DataGrid — page index beyond the last page', () => { test('a stored page past the end lands on the last page when the adapter counts', async () => { + const adapter = createCountingAdapter(7) const storage = createStorageWithPageIndex(14) - const { container } = render() + const { container } = render() await waitFor(() => expect(pageInfo(container)).toBe('Page 4 of 4')) await waitFor(() => expect(getRowCount(container)).toBe(1)) expect(getCellText(container, 0, 'title')).toBe('Article 7') expect(totalInfo(container)).toBe('7 total') expect(storage.written.get('dataview:pageIndex')).toBe(3) + expect(adapter.listOffsets).toEqual([28, 6]) }) - test('a stored page past the end lands on the last page when the adapter does not count', async () => { + test('a stored page past the end bisects to the last page when the adapter does not count', async () => { + const adapter = createCountlessAdapter(7) const storage = createStorageWithPageIndex(14) - const { container } = render() + const { container } = render() await waitFor(() => expect(pageInfo(container)).toBe('Page 4 of 4')) await waitFor(() => expect(getRowCount(container)).toBe(1)) expect(getCellText(container, 0, 'title')).toBe('Article 7') expect(totalInfo(container)).toBe('7 total') expect(storage.written.get('dataview:pageIndex')).toBe(3) + expect(adapter.listOffsets).toEqual([28, 14, 6]) expect((await readAllPages(container)).sort()).toEqual(ALL_SEVEN_TITLES) }) + test('a stored page far past the end costs a logarithmic number of queries when the adapter does not count', async () => { + const adapter = createCountlessAdapter(7) + const { container } = render() + + await waitFor(() => expect(pageInfo(container)).toBe('Page 4 of 4')) + await waitFor(() => expect(getRowCount(container)).toBe(1)) + expect(totalInfo(container)).toBe('7 total') + // The interval of possible totals, [0, 1000] after the first empty page, halves with every query. + expect(adapter.listOffsets.length).toBeLessThanOrEqual(1 + Math.ceil(Math.log2(1000))) + expect(adapter.listOffsets.at(-1)).toBe(6) + }) + + test('an adapter with no rows and no count ends on an empty first page', async () => { + const adapter = createCountlessAdapter(0) + const { container } = render() + + await waitFor(() => expect(pageInfo(container)).toBe('Page 1 of 1')) + await waitFor(() => expect(queryByTestId(container, 'datagrid-empty')).not.toBeNull()) + expect(totalInfo(container)).toBe('0 total') + expect(adapter.listOffsets).toEqual([28, 14, 6, 2, 0]) + }) + test('a narrower filter on the last page moves to the new last page when the adapter does not count', async () => { const adapter = createCountlessAdapter(9) const { container, rerender } = render() @@ -181,6 +216,7 @@ describe('DataGrid — page index beyond the last page', () => { await waitFor(() => expect(pageInfo(container)).toStartWith(`Page ${page}`)) } await waitFor(() => expect(pageInfo(container)).toBe('Page 5 of 5')) + const offsetsBeforeFilter = adapter.listOffsets.length rerender() @@ -189,6 +225,7 @@ describe('DataGrid — page index beyond the last page', () => { expect(getCellText(container, 0, 'title')).toBe('Article 3') expect(totalInfo(container)).toBe('3 total') expect(isDisabled(container, 'datagrid-pagination-next')).toBe(true) + expect(adapter.listOffsets.slice(offsetsBeforeFilter)).toEqual([8, 4, 2]) expect((await readAllPages(container)).sort()).toEqual(['Article 1', 'Article 2', 'Article 3']) }) diff --git a/tests/react/dataview/hasManyDataGridPageIndexOutOfRange.test.tsx b/tests/react/dataview/hasManyDataGridPageIndexOutOfRange.test.tsx new file mode 100644 index 0000000..8e428ff --- /dev/null +++ b/tests/react/dataview/hasManyDataGridPageIndexOutOfRange.test.tsx @@ -0,0 +1,109 @@ +// HasManyDataGrid coverage for https://github.com/contember/bindx/issues/127 when the relation +// reports no total: the grid infers it from the pages it loads for the current query only. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { cleanup, render, waitFor } from '@testing-library/react' +import React from 'react' +import { BindxProvider, Entity, MockAdapter, defineSchema, entityDef, hasMany, scalar } from '@contember/bindx-react' +import type { BackendAdapter, Query, QueryResult, QueryOptions } from '@contember/bindx' +import { DataGridTextColumn, HasManyDataGrid, type StateStorage } from '@contember/bindx-dataview' +import { TestTable, TestPagination, getByTestId, queryByTestId, getRowCount, getCellText } from './helpers.js' + +afterEach(cleanup) + +interface Article { + id: string + title: string +} + +interface Author { + id: string + articles: Article[] +} + +const schema = defineSchema<{ Article: Article; Author: Author }>({ + entities: { + Article: { fields: { id: scalar(), title: scalar() } }, + Author: { fields: { id: scalar(), articles: hasMany('Article') } }, + }, +}) +const authorDef = entityDef('Author') + +/** Records the offset of every relation page and drops the relation's total from the response. */ +class TotallessRelationAdapter implements BackendAdapter { + public readonly relationOffsets: (number | undefined)[] = [] + + constructor(private readonly inner: MockAdapter) {} + + async query(queries: readonly Query[], options?: QueryOptions): Promise { + for (const query of queries) { + const relationField = query.type === 'get' ? query.spec.fields.find(field => field.isArray) : undefined + if (relationField) this.relationOffsets.push(relationField.offset) + } + const results = await this.inner.query(queries, options) + return results.map(result => result.type === 'get' && result.data !== null ? { type: 'get', data: withoutRelationTotals(result.data) } : result) + } + + persist(...args: Parameters): ReturnType { + return this.inner.persist(...args) + } +} + +/** Copying an array keeps its rows and drops the non-enumerable `totalCount` the relation carries. */ +function withoutRelationTotals(data: Record): Record { + return Object.fromEntries(Object.entries(data).map(([key, value]) => [key, Array.isArray(value) ? [...value] : value])) +} + +function createStorageWithPageIndex(key: string, pageIndex: number): StateStorage { + const serialized = new Map([[key, JSON.stringify(pageIndex)]]) + return { + get: (storedKey: string): T | undefined => { + const raw = serialized.get(storedKey) + return raw === undefined ? undefined : JSON.parse(raw) + }, + set: (storedKey: string, value: unknown): void => { + serialized.set(storedKey, JSON.stringify(value)) + }, + remove: (storedKey: string): void => { + serialized.delete(storedKey) + }, + } +} + +describe('HasManyDataGrid — page index beyond the last page', () => { + test('a stored page past the end bisects to the last page when the relation reports no total', async () => { + const articles = Array.from({ length: 7 }, (_, i) => ({ id: `a${i + 1}`, title: `Article ${i + 1}` })) + const adapter = new TotallessRelationAdapter(new MockAdapter({ + Article: Object.fromEntries(articles.map(article => [article.id, article])), + Author: { author: { id: 'author', articles } }, + }, { delay: 0 })) + + const { container } = render( + + + {author => ( + + {it => ( + <> + + + + + )} + + )} + + , + ) + + await waitFor(() => expect(getByTestId(container, 'datagrid-pagination-info').textContent).toBe('Page 4 of 4')) + await waitFor(() => expect(getRowCount(container)).toBe(1)) + expect(getCellText(container, 0, 'title')).toBe('Article 7') + expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('7 total') + expect(adapter.relationOffsets.filter(offset => offset !== undefined)).toEqual([28, 14, 6]) + }) +})