From 480f41c2877542e57fdb623bd7eb11bd5a2bdf10 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 17:04:20 +0100 Subject: [PATCH 01/10] fix(bindx-dataview): refetch the list and its total on reload() reload() in DataGrid, HasManyDataGrid and SelectDataView bumped a counter that nothing read, so the toolbar reload button and useDataViewReload() left stale rows on screen. DataGrid and SelectDataView fold the counter into the list's query key and bump the paging's count refresh token. HasManyDataGrid re-runs its relation query, which also carries the total, and keeps the rows on screen while the same query loads again. The loader state is now derived in render from the list result instead of an effect, and reports `refreshing` while the rows on screen answer an earlier query (stale-while-revalidate). Before, a refetch kept the state at `loaded`, so the reload button never spun and the refreshing overlay never showed. Fixes #124 Co-Authored-By: Claude Opus 5.5 --- packages/bindx-dataview/src/DataGrid.tsx | 39 ++-- .../bindx-dataview/src/HasManyDataGrid.tsx | 39 ++-- .../src/select/SelectDataView.tsx | 39 ++-- packages/bindx-dataview/src/useLoaderState.ts | 18 ++ tests/react/dataview/dataViewReload.test.tsx | 192 ++++++++++++++++++ 5 files changed, 254 insertions(+), 73 deletions(-) create mode 100644 packages/bindx-dataview/src/useLoaderState.ts create mode 100644 tests/react/dataview/dataViewReload.test.tsx diff --git a/packages/bindx-dataview/src/DataGrid.tsx b/packages/bindx-dataview/src/DataGrid.tsx index 4b5ba057..206bc4b1 100644 --- a/packages/bindx-dataview/src/DataGrid.tsx +++ b/packages/bindx-dataview/src/DataGrid.tsx @@ -5,7 +5,7 @@ * For has-many relation grids, use HasManyDataGrid instead. */ -import { memo, type ReactElement, type ReactNode, useEffect, useMemo, useCallback, useRef, useState } from 'react' +import { memo, type ReactElement, type ReactNode, useEffect, useMemo, useCallback, useState } from 'react' import type { EntityDef, EntityAccessor, @@ -21,7 +21,8 @@ import { useEntityCount, } from '@contember/bindx-react' import { useDataViewKey } from './DataViewKeyProvider.js' -import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from './DataViewContext.js' +import { DataViewProvider, type DataViewContextValue } from './DataViewContext.js' +import { useLoaderState } from './useLoaderState.js' import { useDataGridSetup, QUERY_FILTER_NAME } from './useDataGridSetup.js' import { useListFetchAllData } from './useListFetchAllData.js' import { usePagingTotalCount } from './usePagingTotalCount.js' @@ -94,9 +95,13 @@ function DataGridImpl>({ storageKey: effectiveStorageKey, }) - // ---- Loader state tracking ---- - const hasLoadedOnce = useRef(false) - const [loaderState, setLoaderState] = useState('initial') + // ---- Reload: a new query key refetches the page, a new refresh token recounts ---- + const [reloadCounter, setReloadCounter] = useState(0) + const { refreshTotalCount } = setup.paging + const reload = useCallback((): void => { + setReloadCounter(c => c + 1) + refreshTotalCount() + }, [refreshTotalCount]) // ---- Load data via useEntityList ---- const result = useEntityList(entity, { @@ -105,11 +110,13 @@ function DataGridImpl>({ limit: setup.paging.queryLimit, offset: setup.paging.queryOffset, selection: setup.selection, - queryKey: setup.queryKey, + queryKey: `${setup.queryKey}#reload:${reloadCounter}`, }) const items = result.$status === 'ready' ? result.items : [] const itemCount = items.length + const isRefetching = result.$status === 'ready' && result.$isRefetching + const loaderState = useLoaderState(result.$status, isRefetching) // ---- Total count via a standalone count query (keyed on filter only) ---- const { count: totalCount } = useEntityCount(entity, { @@ -117,32 +124,14 @@ function DataGridImpl>({ refreshToken: setup.paging.totalCountRefreshToken, }) - // Update loader state - useEffect(() => { - if (result.$status === 'ready') { - hasLoadedOnce.current = true - setLoaderState('loaded') - } else if (result.$status === 'error') { - setLoaderState('failed') - } else if (result.$status === 'loading') { - setLoaderState(hasLoadedOnce.current ? 'refreshing' : 'initial') - } - }, [result.$status]) - const countKey = useMemo(() => JSON.stringify(setup.combinedFilter ?? {}), [setup.combinedFilter]) usePagingTotalCount(setup.paging, { countedTotal: totalCount, countKey, - isPageCurrent: result.$status === 'ready' && !result.$isRefetching, + isPageCurrent: result.$status === 'ready' && !isRefetching, pageItemCount: itemCount, }) - // ---- Reload ---- - const [, setReloadCounter] = useState(0) - const reload = useCallback((): void => { - setReloadCounter(c => c + 1) - }, []) - // ---- Row highlighting ---- const [highlightIndex, setHighlightIndex] = useState(null) diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index 53c19830..54b359d4 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -19,7 +19,7 @@ * ``` */ -import { memo, type ReactElement, type ReactNode, useEffect, useMemo, useCallback, useRef, useState } from 'react' +import { memo, type ReactElement, type ReactNode, useEffect, useMemo, useCallback, useState } from 'react' import type { EntityAccessor, HasManyRef, @@ -39,7 +39,8 @@ import { useBindxContext, } from '@contember/bindx-react' import { useDataViewKey } from './DataViewKeyProvider.js' -import { DataViewProvider, type DataViewContextValue, type DataViewFetchAllData, type DataViewLoaderState } from './DataViewContext.js' +import { DataViewProvider, type DataViewContextValue, type DataViewFetchAllData } from './DataViewContext.js' +import { useLoaderState } from './useLoaderState.js' import { useDataGridSetup } from './useDataGridSetup.js' import { buildHasManyRelationQuery, extractHasManyRelationRows } from './hasManyRelationQuery.js' import { usePagingTotalCount } from './usePagingTotalCount.js' @@ -83,6 +84,8 @@ interface ListState { totalCount?: number /** The query options the items were loaded for. */ optionsKey?: string + /** The items are shown while the same query loads again. */ + isRefetching?: boolean } const INITIAL_LIST_STATE: ListState = { status: 'loading', items: [] } @@ -138,8 +141,6 @@ function HasManyDataGridImpl({ }) // ---- State ---- - const hasLoadedOnce = useRef(false) - const [loaderState, setLoaderState] = useState('initial') const [listState, setListState] = useState(INITIAL_LIST_STATE) const optionsKey = useMemo( @@ -152,10 +153,18 @@ function HasManyDataGridImpl({ [setup.combinedFilter, setup.sorting.resolvedOrderBy, setup.paging.queryLimit, setup.paging.queryOffset], ) + // ---- Reload: the same query loads again, the relation response carries the total ---- + const [reloadCounter, setReloadCounter] = useState(0) + const reload = useCallback((): void => { + setReloadCounter(c => c + 1) + }, []) + // ---- Data loading via GetQuery on parent ---- useEffect(() => { const abortController = new AbortController() - setListState(INITIAL_LIST_STATE) + setListState(previous => previous.status === 'ready' && previous.optionsKey === optionsKey + ? { ...previous, isRefetching: true } + : INITIAL_LIST_STATE) const fetchData = async (): Promise => { try { @@ -212,7 +221,7 @@ function HasManyDataGridImpl({ return () => { abortController.abort() } - }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, setup.selection, batcher, dispatcher, store, schemaRegistry]) + }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, reloadCounter, setup.selection, batcher, dispatcher, store, schemaRegistry]) // ---- Build items from state ---- const items = useMemo((): EntityAccessor[] => { @@ -230,17 +239,7 @@ function HasManyDataGridImpl({ const itemCount = items.length - // Update loader state - useEffect(() => { - if (listState.status === 'ready') { - hasLoadedOnce.current = true - setLoaderState('loaded') - } else if (listState.status === 'error') { - setLoaderState('failed') - } else if (listState.status === 'loading') { - setLoaderState(hasLoadedOnce.current ? 'refreshing' : 'initial') - } - }, [listState.status]) + const loaderState = useLoaderState(listState.status, listState.isRefetching ?? false) const countKey = useMemo( () => JSON.stringify([parentEntityType, parentEntityId, fieldName, setup.combinedFilter ?? {}]), @@ -253,12 +252,6 @@ function HasManyDataGridImpl({ pageItemCount: itemCount, }) - // ---- Reload ---- - const [, setReloadCounter] = useState(0) - const reload = useCallback((): void => { - setReloadCounter(c => c + 1) - }, []) - // ---- Row highlighting ---- const [highlightIndex, setHighlightIndex] = useState(null) diff --git a/packages/bindx-dataview/src/select/SelectDataView.tsx b/packages/bindx-dataview/src/select/SelectDataView.tsx index aa2a8357..6d51771f 100644 --- a/packages/bindx-dataview/src/select/SelectDataView.tsx +++ b/packages/bindx-dataview/src/select/SelectDataView.tsx @@ -43,7 +43,8 @@ import { collectSelection, } from '@contember/bindx-react' import { useSelectOptions } from './selectContext.js' -import { DataViewProvider, type DataViewContextValue, type DataViewLoaderState } from '../DataViewContext.js' +import { DataViewProvider, type DataViewContextValue } from '../DataViewContext.js' +import { useLoaderState } from '../useLoaderState.js' import { useFilteringState, useSortingState, usePagingState, useSelectionState } from '../useDataViewState.js' import { useListFetchAllData } from '../useListFetchAllData.js' import { usePagingTotalCount } from '../usePagingTotalCount.js' @@ -77,10 +78,6 @@ function SelectDataViewImpl({ const { schema: schemaRegistry } = useBindxContext() const entityType = options.$name - // ---- Loader state tracking ---- - const hasLoadedOnce = useRef(false) - const [loaderState, setLoaderState] = useState('initial') - // ---- Build selection from definer ---- // Use ref for selectionDefiner to avoid re-running on every render // (inline arrow functions create new refs each render) @@ -155,6 +152,14 @@ function SelectDataViewImpl({ return { and: parts } }, [staticFilter, filtering.resolvedWhere]) + // ---- Reload: a new query key refetches the page, a new refresh token recounts ---- + const [reloadCounter, setReloadCounter] = useState(0) + const { refreshTotalCount } = paging + const reload = useCallback((): void => { + setReloadCounter(c => c + 1) + refreshTotalCount() + }, [refreshTotalCount]) + // ---- Load data ---- const result = useEntityList(options, { filter: combinedFilter, @@ -162,11 +167,13 @@ function SelectDataViewImpl({ limit: paging.queryLimit, offset: paging.queryOffset, selection, - queryKey, + queryKey: `${queryKey}#reload:${reloadCounter}`, }) const items = result.$status === 'ready' ? result.items : [] const itemCount = items.length + const isRefetching = result.$status === 'ready' && result.$isRefetching + const loaderState = useLoaderState(result.$status, isRefetching) // ---- Total count via a standalone count query (keyed on filter only) ---- const { count: totalCount } = useEntityCount(options, { @@ -174,32 +181,14 @@ function SelectDataViewImpl({ refreshToken: paging.totalCountRefreshToken, }) - // Update loader state - useEffect(() => { - if (result.$status === 'ready') { - hasLoadedOnce.current = true - setLoaderState('loaded') - } else if (result.$status === 'error') { - setLoaderState('failed') - } else if (result.$status === 'loading') { - setLoaderState(hasLoadedOnce.current ? 'refreshing' : 'initial') - } - }, [result.$status]) - const countKey = useMemo(() => JSON.stringify(combinedFilter ?? {}), [combinedFilter]) usePagingTotalCount(paging, { countedTotal: totalCount, countKey, - isPageCurrent: result.$status === 'ready' && !result.$isRefetching, + isPageCurrent: result.$status === 'ready' && !isRefetching, pageItemCount: itemCount, }) - // ---- Reload ---- - const [, setReloadCounter] = useState(0) - const reload = useCallback((): void => { - setReloadCounter(c => c + 1) - }, []) - // ---- Row highlighting ---- const [highlightIndex, setHighlightIndex] = useState(null) diff --git a/packages/bindx-dataview/src/useLoaderState.ts b/packages/bindx-dataview/src/useLoaderState.ts new file mode 100644 index 00000000..c7170099 --- /dev/null +++ b/packages/bindx-dataview/src/useLoaderState.ts @@ -0,0 +1,18 @@ +import { useRef } from 'react' +import type { DataViewLoaderState } from './DataViewContext.js' + +export type ListLoadStatus = 'loading' | 'error' | 'ready' + +/** + * Derives the loader state in render, so it always describes the rows rendered with it. + * `isRefetching`: the rows on screen answer an earlier query while the current one loads. + */ +export function useLoaderState(status: ListLoadStatus, isRefetching: boolean): DataViewLoaderState { + const hasLoadedOnce = useRef(false) + if (status === 'ready') { + hasLoadedOnce.current = true + return isRefetching ? 'refreshing' : 'loaded' + } + if (status === 'error') return 'failed' + return hasLoadedOnce.current ? 'refreshing' : 'initial' +} diff --git a/tests/react/dataview/dataViewReload.test.tsx b/tests/react/dataview/dataViewReload.test.tsx new file mode 100644 index 00000000..c2da96fe --- /dev/null +++ b/tests/react/dataview/dataViewReload.test.tsx @@ -0,0 +1,192 @@ +// Issue #124: reload() from the data view context never refetched the list or its count. +// A row changed by a mutation outside the view must leave (or join) it after reload(). +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import React, { type ReactElement } from 'react' +import { + BindxProvider, + Entity, + MockAdapter, + defineSchema, + entityDef, + hasMany, + hasOne, + scalar, + useEntity, +} from '@contember/bindx-react' +import { + DataGrid, + DataGridTextColumn, + DataViewEachRow, + HasManyDataGrid, + Select, + SelectDataView, + useDataViewContext, + useDataViewReload, +} from '@contember/bindx-dataview' +import { TestPagination, TestTable, getByTestId, getRowCount, queryByTestId } from './helpers.js' + +afterEach(cleanup) + +interface Task { + id: string + title: string + finished: boolean +} + +interface Project { + id: string + tasks: Task[] + lead: Task | null +} + +const schema = defineSchema<{ Task: Task; Project: Project }>({ + entities: { + Task: { fields: { id: scalar(), title: scalar(), finished: scalar() } }, + Project: { fields: { id: scalar(), tasks: hasMany('Task'), lead: hasOne('Task') } }, + }, +}) +const taskDef = entityDef('Task') +const projectDef = entityDef('Project') + +function createAdapter(): MockAdapter { + const tasks = [1, 2, 3].map(i => ({ id: `t${i}`, title: `Task ${i}`, finished: false })) + return new MockAdapter({ + Task: Object.fromEntries(tasks.map(task => [task.id, { ...task }])), + Project: { p1: { id: 'p1', tasks, lead: null } }, + }, { delay: 20 }) +} + +/** Finishes a task behind the view's back, as a separate mutation would. */ +function finishTaskOnServer(adapter: MockAdapter, id: string): void { + const store = adapter.getStore() + const task = store['Task']?.[id] + if (!task) throw new Error(`no task ${id}`) + task['finished'] = true + const project = store['Project']?.['p1'] + const projectTasks = project?.['tasks'] + if (project && Array.isArray(projectTasks)) { + project['tasks'] = projectTasks.filter(it => it.id !== id) + } +} + +function ReloadProbe(): ReactElement { + const reload = useDataViewReload() + const { loaderState } = useDataViewContext() + return ( + <> + + {loaderState} + + ) +} + +async function clickReload(container: HTMLElement): Promise { + await act(async () => { + fireEvent.click(getByTestId(container, 'reload')) + }) +} + +const openTasks = { finished: { eq: false } } + +describe('data view reload()', () => { + test('DataGrid refetches the rows and the total, keeping the rows while it loads', async () => { + const adapter = createAdapter() + const { container } = render( + + + {it => ( + <> + + + + + + )} + + , + ) + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('3 total')) + expect(getRowCount(container)).toBe(3) + + finishTaskOnServer(adapter, 't1') + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(getRowCount(container)).toBe(3) + await waitFor(() => expect(getRowCount(container)).toBe(2)) + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('2 total')) + expect(getByTestId(container, 'loader-state').textContent).toBe('loaded') + }) + + test('HasManyDataGrid refetches the relation rows and the total, keeping the rows while it loads', async () => { + const adapter = createAdapter() + const { container } = render( + + + {project => ( + + {it => ( + <> + + + + + + )} + + )} + + , + ) + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('3 total')) + expect(getRowCount(container)).toBe(3) + + finishTaskOnServer(adapter, 't1') + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(getRowCount(container)).toBe(3) + await waitFor(() => expect(getRowCount(container)).toBe(2)) + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('2 total')) + expect(getByTestId(container, 'loader-state').textContent).toBe('loaded') + }) + + test('SelectDataView refetches the options and the total', async () => { + const adapter = createAdapter() + + function Picker(): ReactElement { + const project = useEntity(projectDef, { by: { id: 'p1' } }, e => e.id().lead(t => t.id().title())) + if (project.$status !== 'ready') return + return ( + + ) + } + + const { container } = render( + + + , + ) + const optionCount = (): number => container.querySelectorAll('[data-testid="option"]').length + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('3 total')) + expect(optionCount()).toBe(3) + + finishTaskOnServer(adapter, 't1') + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(optionCount()).toBe(3) + await waitFor(() => expect(optionCount()).toBe(2)) + await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('2 total')) + }) +}) From 853511c7dc0a199215327976ac09994f4fb28972 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 17:04:29 +0100 Subject: [PATCH 02/10] fix(bindx-dataview): start a new select search on the first page SelectDataView kept the page index of the previous search, so a search narrowed after scrolling went out at the old offset and showed nothing until the count caught up. It now resets paging to the first page when its filter (search and static filter) changes, during render, so no query is sent at the old offset. The paging also judged `hasNext` by a counted total that can still describe the previous search while the new count loads, so the sentinel of a short list loaded an empty page. usePagingTotalCount now lets a current page shorter than the limit set the total: such a page ends the list whatever the count says. Fixes #145 Co-Authored-By: Claude Opus 5.5 --- .../src/select/SelectDataView.tsx | 9 +- .../bindx-dataview/src/usePagingTotalCount.ts | 30 +++- .../dataview/selectDataViewPaging.test.tsx | 156 ++++++++++++++++++ 3 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 tests/react/dataview/selectDataViewPaging.test.tsx diff --git a/packages/bindx-dataview/src/select/SelectDataView.tsx b/packages/bindx-dataview/src/select/SelectDataView.tsx index 6d51771f..107c0549 100644 --- a/packages/bindx-dataview/src/select/SelectDataView.tsx +++ b/packages/bindx-dataview/src/select/SelectDataView.tsx @@ -151,6 +151,14 @@ function SelectDataViewImpl({ if (parts.length === 1) return parts[0] return { and: parts } }, [staticFilter, filtering.resolvedWhere]) + const countKey = useMemo(() => JSON.stringify(combinedFilter ?? {}), [combinedFilter]) + + // A new search starts on the first page. Reset during render, so no query goes out at the old offset. + const [pagedFilterKey, setPagedFilterKey] = useState(countKey) + if (pagedFilterKey !== countKey) { + setPagedFilterKey(countKey) + paging.first() + } // ---- Reload: a new query key refetches the page, a new refresh token recounts ---- const [reloadCounter, setReloadCounter] = useState(0) @@ -181,7 +189,6 @@ function SelectDataViewImpl({ refreshToken: paging.totalCountRefreshToken, }) - const countKey = useMemo(() => JSON.stringify(combinedFilter ?? {}), [combinedFilter]) usePagingTotalCount(paging, { countedTotal: totalCount, countKey, diff --git a/packages/bindx-dataview/src/usePagingTotalCount.ts b/packages/bindx-dataview/src/usePagingTotalCount.ts index b716738b..3e6777e7 100644 --- a/packages/bindx-dataview/src/usePagingTotalCount.ts +++ b/packages/bindx-dataview/src/usePagingTotalCount.ts @@ -31,8 +31,9 @@ type PageOutcome = 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 the loaded pages prove. Only a page that answers the current query counts. + * Feeds the paging state its total: the total a current page proves by ending the list, else the counted + * total when the data source reports one, else the total the loaded pages prove. Only a page that answers + * the current query counts. * * 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. @@ -46,15 +47,19 @@ export function usePagingTotalCount(paging: PagingStateResult, source: PagingTot if (boundsRef.current.countKey !== countKey || countedTotal !== null) { boundsRef.current = { countKey, bounds: NO_BOUNDS } } + const page = isPageCurrent && queryLimit !== undefined && queryOffset !== undefined + ? { offset: queryOffset, limit: queryLimit, itemCount: pageItemCount } + : null if (countedTotal !== null) { - setTotalCount(countedTotal) + // The count may still describe an earlier row set; a current page that ends the list does not. + setTotalCount((page && totalEndedByPage(page)) ?? countedTotal) return } - if (!isPageCurrent || queryLimit === undefined || queryOffset === undefined) { + if (!page) { return } - const outcome = inferTotalFromPage(boundsRef.current.bounds, { offset: queryOffset, limit: queryLimit, itemCount: pageItemCount }) + const outcome = inferTotalFromPage(boundsRef.current.bounds, page) if (outcome.type === 'total') { boundsRef.current = { countKey, bounds: NO_BOUNDS } setTotalCount(outcome.totalCount) @@ -67,12 +72,21 @@ export function usePagingTotalCount(paging: PagingStateResult, source: PagingTot }, [countedTotal, countKey, isPageCurrent, pageItemCount, queryLimit, queryOffset, setTotalCount, goTo]) } -function inferTotalFromPage(bounds: TotalBounds, { offset, limit, itemCount }: LoadedPage): PageOutcome { +/** The total, when the page is shorter than the limit and so ends the list. An empty page past offset 0 proves no total. */ +function totalEndedByPage({ offset, limit, itemCount }: LoadedPage): number | null { const isEmptyPastStart = itemCount === 0 && offset > 0 - if (itemCount < limit && !isEmptyPastStart) { - return { type: 'total', totalCount: offset + itemCount } + return itemCount < limit && !isEmptyPastStart ? offset + itemCount : null +} + +function inferTotalFromPage(bounds: TotalBounds, page: LoadedPage): PageOutcome { + const { offset, limit, itemCount } = page + const endedTotal = totalEndedByPage(page) + if (endedTotal !== null) { + return { type: 'total', totalCount: endedTotal } } + const isEmptyPastStart = itemCount === 0 && offset > 0 + const narrowed: TotalBounds = isEmptyPastStart ? { atLeast: bounds.atLeast, atMost: Math.min(bounds.atMost ?? offset, offset) } : { atLeast: Math.max(bounds.atLeast, offset + limit), atMost: bounds.atMost } diff --git a/tests/react/dataview/selectDataViewPaging.test.tsx b/tests/react/dataview/selectDataViewPaging.test.tsx new file mode 100644 index 00000000..d07dd629 --- /dev/null +++ b/tests/react/dataview/selectDataViewPaging.test.tsx @@ -0,0 +1,156 @@ +// Issue #145: a narrowed search in SelectDataView kept the page index of the previous search, +// and judged `hasNext` by a total that did not belong to the rows on screen. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import React, { type ReactElement } from 'react' +import { BindxProvider, MockAdapter, defineSchema, entityDef, hasOne, scalar, useEntity } from '@contember/bindx-react' +import type { BackendAdapter, Query, QueryOptions, QueryResult } from '@contember/bindx' +import { + DataViewEachRow, + QUERY_FILTER_NAME, + Select, + SelectDataView, + useDataViewContext, +} from '@contember/bindx-dataview' +import { getByTestId, queryByTestId } from './helpers.js' + +afterEach(cleanup) + +interface Tag { + id: string + name: string +} + +interface Article { + id: string + tag: Tag | null +} + +const schema = defineSchema<{ Tag: Tag; Article: Article }>({ + entities: { + Tag: { fields: { id: scalar(), name: scalar() } }, + Article: { fields: { id: scalar(), tag: hasOne('Tag') } }, + }, +}) +const tagDef = entityDef('Tag') +const articleDef = entityDef
('Article') + +// 120 tags named "Tag 001" … "Tag 120"; three of them also match "special". +function createStore(): Record>> { + const Tag: Record> = {} + for (let i = 1; i <= 120; i++) { + const id = `t${String(i).padStart(3, '0')}` + const special = i % 40 === 0 ? ' special' : '' + Tag[id] = { id, name: `Tag ${String(i).padStart(3, '0')}${special}` } + } + return { Tag, Article: { a1: { id: 'a1', tag: null } } } +} + +interface ListRequest { + readonly filter: string + readonly offset: number | undefined +} + +/** Records every list request; can answer count queries with the unfiltered total, as a count that lags behind would. */ +class SpyAdapter implements BackendAdapter { + readonly listRequests: ListRequest[] = [] + + constructor(private readonly inner: MockAdapter, private readonly countIgnoresFilter = false) {} + + async query(queries: readonly Query[], options?: QueryOptions): Promise { + for (const query of queries) { + if (query.type === 'list' && query.entityType === 'Tag') { + this.listRequests.push({ filter: JSON.stringify(query.filter ?? {}), offset: query.offset }) + } + } + const forwarded = this.countIgnoresFilter + ? queries.map(query => query.type === 'count' ? { ...query, filter: undefined } : query) + : queries + return this.inner.query(forwarded, options) + } + + persist(...args: Parameters): ReturnType { + return this.inner.persist(...args) + } +} + +function Controls(): ReactElement { + const { filtering, paging, items, loaderState } = useDataViewContext() + return ( + <> + + + {paging.state.pageIndex} + {String(paging.hasNext)} + {items.length} + {loaderState} + + ) +} + +function renderPicker(adapter: BackendAdapter): HTMLElement { + function Picker(): ReactElement { + const article = useEntity(articleDef, { by: { id: 'a1' } }, e => e.id().tag(t => t.id().name())) + if (article.$status !== 'ready') return + return ( + + ) + } + const { container } = render( + + + , + ) + return container +} + +const text = (container: HTMLElement, testId: string): string | null => getByTestId(container, testId).textContent + +async function click(container: HTMLElement, testId: string): Promise { + await act(async () => { + fireEvent.click(getByTestId(container, testId)) + }) +} + +describe('SelectDataView paging', () => { + test('a new search starts on the first page', async () => { + const adapter = new SpyAdapter(new MockAdapter(createStore(), { delay: 0 })) + const container = renderPicker(adapter) + await waitFor(() => expect(queryByTestId(container, 'item-count')?.textContent).toBe('50')) + + await click(container, 'next') + await waitFor(() => expect(text(container, 'page')).toBe('1')) + await waitFor(() => expect(text(container, 'loader-state')).toBe('loaded')) + + await click(container, 'search-special') + await waitFor(() => expect(text(container, 'item-count')).toBe('3')) + + expect(text(container, 'page')).toBe('0') + const searchRequests = adapter.listRequests.filter(request => request.filter.includes('special')) + expect(searchRequests.map(request => request.offset)).toEqual([0]) + }) + + test('a short page has no next page, whatever the counted total says', async () => { + const adapter = new SpyAdapter(new MockAdapter(createStore(), { delay: 0 }), true) + const container = renderPicker(adapter) + await waitFor(() => expect(queryByTestId(container, 'item-count')?.textContent).toBe('50')) + expect(text(container, 'has-next')).toBe('true') + + await click(container, 'search-special') + await waitFor(() => expect(text(container, 'item-count')).toBe('3')) + await waitFor(() => expect(text(container, 'loader-state')).toBe('loaded')) + + expect(text(container, 'has-next')).toBe('false') + }) +}) From 123b5d6a505f0be31626921a7f363f94b9d2aeec Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 17:04:37 +0100 Subject: [PATCH 03/10] fix(bindx-dataview): accumulate pages in DataViewInfiniteLoadProvider The provider kept `pages` in state but never filled it, and rendered the view's `items`, which hold only the current page, so "load more" replaced the rows. `isLoadingMore` was set once the offset moved and never reset, so no page past the second was ever requested: no bindx-ui select could reach options past the first 100. loadMore now stores the current page and moves the view to the next one; two calls before the next render request one page. Children get a data view whose `items` are the stored pages followed by the current one (each row once), so DataViewEachRow, the keyboard handler and the highlight indices see the whole list. Stored pages belong to one query (filter, order, page size) and to an unbroken run of offsets from the first row; anything else shows only the current page. Loading follows the view's loader state, so it ends when the page lands, empty or not. Fixes #146 Co-Authored-By: Claude Opus 5.5 --- packages/bindx-dataview/src/infiniteLoad.tsx | 119 ++++++++------ tests/react/dataview/infiniteLoad.test.tsx | 155 +++++++++++++++++++ 2 files changed, 227 insertions(+), 47 deletions(-) create mode 100644 tests/react/dataview/infiniteLoad.test.tsx diff --git a/packages/bindx-dataview/src/infiniteLoad.tsx b/packages/bindx-dataview/src/infiniteLoad.tsx index b37005f4..a1153fbc 100644 --- a/packages/bindx-dataview/src/infiniteLoad.tsx +++ b/packages/bindx-dataview/src/infiniteLoad.tsx @@ -18,7 +18,7 @@ import React, { createContext, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactElement } from 'react' import { Slot } from '@radix-ui/react-slot' import { composeEventHandlers } from '@radix-ui/primitive' -import { useDataViewContext } from './DataViewContext.js' +import { DataViewProvider, useDataViewContext, type DataViewContextValue, type DataViewItem } from './DataViewContext.js' // ============================================================================ // Context @@ -31,7 +31,14 @@ interface InfiniteLoadContextValue { } interface PageData { - readonly items: readonly { id: string; data: Record }[] + readonly offset: number + readonly items: readonly DataViewItem[] +} + +/** Pages loaded before the current one, for the query (filter, order, page size) they belong to. */ +interface StoredPages { + readonly queryKey: string + readonly pages: readonly PageData[] } const InfiniteLoadContext = createContext(null) @@ -44,6 +51,26 @@ function useInfiniteLoadContext(): InfiniteLoadContextValue { return ctx } +/** The stored pages that run from the first row up to the current offset, or none when the view jumped. */ +function pagesBefore(pages: readonly PageData[], offset: number | undefined, limit: number | undefined): readonly PageData[] { + if (offset === undefined || limit === undefined) return [] + const run = pages.filter(page => page.offset < offset) + const isContiguous = run.length * limit === offset && run.every((page, index) => page.offset === index * limit) + return isContiguous ? run : [] +} + +function joinPages(pages: readonly PageData[], currentItems: readonly DataViewItem[]): readonly DataViewItem[] { + if (pages.length === 0) return currentItems + const seen = new Set() + const items: DataViewItem[] = [] + for (const item of [...pages.flatMap(page => page.items), ...currentItems]) { + if (seen.has(item.id)) continue + seen.add(item.id) + items.push(item) + } + return items +} + // ============================================================================ // Provider // ============================================================================ @@ -52,55 +79,51 @@ export interface DataViewInfiniteLoadProviderProps { children: React.ReactNode } +/** + * Shows the pages loaded so far as one list: its children see the stored pages plus the current one + * as the data view's `items`. `loadMore` stores the current page and moves the view to the next one. + */ export function DataViewInfiniteLoadProvider({ children, }: DataViewInfiniteLoadProviderProps): ReactElement { - const { paging, filtering, sorting } = useDataViewContext() - const [pages, setPages] = useState([]) - const [isLoadingMore, setIsLoadingMore] = useState(false) - - const filterKey = JSON.stringify(filtering.resolvedWhere ?? null) - const sortKey = JSON.stringify(sorting.resolvedOrderBy ?? null) - - const previousParams = useRef<{ - filterKey: string - sortKey: string - limit: number | undefined - offset: number | undefined - } | null>(null) - - const currentParams = useMemo(() => ({ - filterKey, - sortKey, - limit: paging.queryLimit, - offset: paging.queryOffset, - }), [filterKey, sortKey, paging.queryLimit, paging.queryOffset]) + const dataView = useDataViewContext() + const { paging, filtering, sorting, items, loaderState } = dataView + const { queryOffset: offset, queryLimit: limit, hasNext, next } = paging + const queryKey = JSON.stringify([filtering.resolvedWhere ?? null, sorting.resolvedOrderBy ?? null, limit ?? null]) + + const [stored, setStored] = useState({ queryKey, pages: [] }) + const pages = useMemo( + () => pagesBefore(stored.queryKey === queryKey ? stored.pages : [], offset, limit), + [stored, queryKey, offset, limit], + ) - useEffect(() => { - const prev = previousParams.current - if (!prev) { - setPages([]) - } else if ( - prev.filterKey === currentParams.filterKey - && prev.sortKey === currentParams.sortKey - && prev.limit === currentParams.limit - && prev.limit !== undefined - && prev.offset !== undefined - && currentParams.offset === prev.offset + prev.limit - ) { - setIsLoadingMore(true) - } else { - setPages([]) - } - previousParams.current = currentParams - }, [currentParams]) + // The rows on screen answer the current offset only once the view has loaded it. + const isPageLoaded = loaderState === 'loaded' + const isLoadingMore = loaderState === 'initial' || loaderState === 'refreshing' - const hasNext = paging.hasNext + // Two calls before the next render must request one page. + const requestedFromRef = useRef(null) + const requestKey = `${queryKey}@${offset ?? ''}` + useEffect(() => { + requestedFromRef.current = null + }, [requestKey, loaderState]) const loadMore = useMemo((): (() => void) | undefined => { - if (!hasNext) return undefined - return () => paging.next() - }, [hasNext, paging]) + if (!hasNext || offset === undefined) return undefined + return () => { + if (!isPageLoaded || requestedFromRef.current === requestKey) return + requestedFromRef.current = requestKey + const currentPage: PageData = { offset, items } + setStored({ queryKey, pages: [...pages, currentPage] }) + next() + } + }, [hasNext, isPageLoaded, offset, requestKey, queryKey, pages, items, next]) + + const shownItems = useMemo(() => joinPages(pages, items), [pages, items]) + const dataViewValue = useMemo( + (): DataViewContextValue => ({ ...dataView, items: shownItems, itemCount: shownItems.length }), + [dataView, shownItems], + ) const contextValue = useMemo((): InfiniteLoadContextValue => ({ pages, @@ -109,9 +132,11 @@ export function DataViewInfiniteLoadProvider({ }), [pages, loadMore, isLoadingMore]) return ( - - {children} - + + + {children} + + ) } diff --git a/tests/react/dataview/infiniteLoad.test.tsx b/tests/react/dataview/infiniteLoad.test.tsx new file mode 100644 index 00000000..e123a80e --- /dev/null +++ b/tests/react/dataview/infiniteLoad.test.tsx @@ -0,0 +1,155 @@ +// Issue #146: DataViewInfiniteLoadProvider replaced the rows with each page instead of accumulating them, +// and never cleared its loading flag, so no page past the second could be loaded. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import React, { type ReactElement } from 'react' +import { BindxProvider, MockAdapter, defineSchema, entityDef, hasOne, scalar, useEntity } from '@contember/bindx-react' +import { + DataViewEachRow, + DataViewInfiniteLoadProvider, + DataViewInfiniteLoadTrigger, + QUERY_FILTER_NAME, + Select, + SelectDataView, + useDataViewContext, +} from '@contember/bindx-dataview' +import { getByTestId, queryByTestId } from './helpers.js' + +afterEach(cleanup) + +interface Tag { + id: string + name: string +} + +interface Article { + id: string + tag: Tag | null +} + +const schema = defineSchema<{ Tag: Tag; Article: Article }>({ + entities: { + Tag: { fields: { id: scalar(), name: scalar() } }, + Article: { fields: { id: scalar(), tag: hasOne('Tag') } }, + }, +}) +const tagDef = entityDef('Tag') +const articleDef = entityDef
('Article') + +const TAG_COUNT = 106 + +function tagId(i: number): string { + return `t${String(i).padStart(3, '0')}` +} + +// 106 tags; every tenth one also matches "ten". +function createAdapter(): MockAdapter { + const Tag: Record> = {} + for (let i = 1; i <= TAG_COUNT; i++) { + Tag[tagId(i)] = { id: tagId(i), name: `Tag ${String(i).padStart(3, '0')}${i % 10 === 0 ? ' ten' : ''}` } + } + return new MockAdapter({ Tag, Article: { a1: { id: 'a1', tag: null } } }, { delay: 10 }) +} + +function SearchButton(): ReactElement { + const { filtering } = useDataViewContext() + return ( + + ) +} + +function renderPicker(adapter: MockAdapter): HTMLElement { + function Picker(): ReactElement { + const article = useEntity(articleDef, { by: { id: 'a1' } }, e => e.id().tag(t => t.id().name())) + if (article.$status !== 'ready') return + return ( + + ) + } + const { container } = render( + + + , + ) + return container +} + +function optionIds(container: HTMLElement): string[] { + return Array.from(container.querySelectorAll('[data-testid="option"]')).map(el => el.getAttribute('data-id') ?? '') +} + +function loadMoreButton(container: HTMLElement): HTMLButtonElement | null { + const button = queryByTestId(container, 'load-more') + return button instanceof HTMLButtonElement ? button : null +} + +async function loadMore(container: HTMLElement): Promise { + await waitFor(() => expect(loadMoreButton(container)?.disabled).toBe(false)) + await act(async () => { + fireEvent.click(getByTestId(container, 'load-more')) + }) +} + +const expectedIds = (count: number): string[] => Array.from({ length: count }, (_, i) => tagId(i + 1)) + +describe('DataViewInfiniteLoadProvider', () => { + test('each loaded page is appended, page after page, until the list ends', async () => { + const container = renderPicker(createAdapter()) + await waitFor(() => expect(optionIds(container)).toHaveLength(50)) + + await loadMore(container) + await waitFor(() => expect(optionIds(container)).toHaveLength(100)) + expect(optionIds(container)).toEqual(expectedIds(100)) + + await loadMore(container) + await waitFor(() => expect(optionIds(container)).toHaveLength(TAG_COUNT)) + expect(optionIds(container)).toEqual(expectedIds(TAG_COUNT)) + const indices = Array.from(container.querySelectorAll('[data-testid="option"]')).map(el => el.getAttribute('data-index')) + expect(indices).toEqual(Array.from({ length: TAG_COUNT }, (_, i) => String(i))) + + await waitFor(() => expect(loadMoreButton(container)).toBeNull()) + }) + + test('two calls before the next render load one page', async () => { + const container = renderPicker(createAdapter()) + await waitFor(() => expect(optionIds(container)).toHaveLength(50)) + await waitFor(() => expect(loadMoreButton(container)?.disabled).toBe(false)) + + await act(async () => { + fireEvent.click(getByTestId(container, 'load-more')) + fireEvent.click(getByTestId(container, 'load-more')) + }) + await waitFor(() => expect(optionIds(container)).toHaveLength(100)) + expect(optionIds(container)).toEqual(expectedIds(100)) + }) + + test('a new search drops the pages of the previous one', async () => { + const container = renderPicker(createAdapter()) + await waitFor(() => expect(optionIds(container)).toHaveLength(50)) + await loadMore(container) + await waitFor(() => expect(optionIds(container)).toHaveLength(100)) + + await act(async () => { + fireEvent.click(getByTestId(container, 'search-ten')) + }) + await waitFor(() => expect(optionIds(container)).toEqual(Array.from({ length: 10 }, (_, i) => tagId((i + 1) * 10)))) + }) +}) From 7b7049a609ea5764060208a81edbf46a3e1857a7 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 17:11:38 +0100 Subject: [PATCH 04/10] fix(bindx-dataview): keep DataViewNonEmpty content mounted while refreshing Since views report `refreshing` during a stale-while-revalidate refetch, DataViewNonEmpty, which rendered only on `loaded`, unmounted the default grid table (header, filter popovers, rows) on every page, sort, filter change and reload. It now also renders on `refreshing` while rows are on screen. DataViewEmpty still waits for `loaded`, so a view without rows does not flash its empty state while it loads. Refs #124 Co-Authored-By: Claude Opus 5.5 --- .../bindx-dataview/src/loaderComponents.tsx | 4 ++- tests/react/dataview/dataViewReload.test.tsx | 29 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/bindx-dataview/src/loaderComponents.tsx b/packages/bindx-dataview/src/loaderComponents.tsx index edc737f1..da185e7b 100644 --- a/packages/bindx-dataview/src/loaderComponents.tsx +++ b/packages/bindx-dataview/src/loaderComponents.tsx @@ -146,7 +146,9 @@ export interface DataViewNonEmptyProps { export function DataViewNonEmpty({ children }: DataViewNonEmptyProps): ReactElement { const { loaderState, itemCount } = useDataViewContext() - if (loaderState !== 'loaded' || itemCount === 0) return <>{null} + // Rows of an earlier query stay mounted while the current one loads. + const isShowingRows = loaderState === 'loaded' || loaderState === 'refreshing' + if (!isShowingRows || itemCount === 0) return <>{null} return <>{children} } diff --git a/tests/react/dataview/dataViewReload.test.tsx b/tests/react/dataview/dataViewReload.test.tsx index c2da96fe..37176742 100644 --- a/tests/react/dataview/dataViewReload.test.tsx +++ b/tests/react/dataview/dataViewReload.test.tsx @@ -19,6 +19,7 @@ import { DataGrid, DataGridTextColumn, DataViewEachRow, + DataViewNonEmpty, HasManyDataGrid, Select, SelectDataView, @@ -189,4 +190,32 @@ describe('data view reload()', () => { await waitFor(() => expect(optionCount()).toBe(2)) await waitFor(() => expect(queryByTestId(container, 'datagrid-pagination-total')?.textContent).toBe('2 total')) }) + + test('rows inside DataViewNonEmpty stay mounted while the grid refetches', async () => { + const adapter = createAdapter() + const { container } = render( + + + {it => ( + <> + + + {item => } + + + + )} + + , + ) + const rowCount = (): number => container.querySelectorAll('[data-testid="row"]').length + await waitFor(() => expect(rowCount()).toBe(3)) + + finishTaskOnServer(adapter, 't1') + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(rowCount()).toBe(3) + await waitFor(() => expect(rowCount()).toBe(2)) + }) }) From 6431851eafd132497250c29f0f935c5dc334b2de Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 17:12:00 +0100 Subject: [PATCH 05/10] fix(bindx-dataview): reload an infinite list from its first page The view refetches only its current page, so reload() in an infinite-scroll list kept the earlier pages the provider had stored. The provider now hands its children a reload that moves the view back to the first page and refetches it. Refs #146 Co-Authored-By: Claude Opus 5.5 --- packages/bindx-dataview/src/infiniteLoad.tsx | 17 ++++++++++----- tests/react/dataview/infiniteLoad.test.tsx | 22 ++++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/packages/bindx-dataview/src/infiniteLoad.tsx b/packages/bindx-dataview/src/infiniteLoad.tsx index a1153fbc..a44c9a36 100644 --- a/packages/bindx-dataview/src/infiniteLoad.tsx +++ b/packages/bindx-dataview/src/infiniteLoad.tsx @@ -81,14 +81,15 @@ export interface DataViewInfiniteLoadProviderProps { /** * Shows the pages loaded so far as one list: its children see the stored pages plus the current one - * as the data view's `items`. `loadMore` stores the current page and moves the view to the next one. + * as the data view's `items`. `loadMore` stores the current page and moves the view to the next one; + * `reload` loads the list again from the first page. */ export function DataViewInfiniteLoadProvider({ children, }: DataViewInfiniteLoadProviderProps): ReactElement { const dataView = useDataViewContext() - const { paging, filtering, sorting, items, loaderState } = dataView - const { queryOffset: offset, queryLimit: limit, hasNext, next } = paging + const { paging, filtering, sorting, items, loaderState, reload: reloadPage } = dataView + const { queryOffset: offset, queryLimit: limit, hasNext, next, first } = paging const queryKey = JSON.stringify([filtering.resolvedWhere ?? null, sorting.resolvedOrderBy ?? null, limit ?? null]) const [stored, setStored] = useState({ queryKey, pages: [] }) @@ -119,10 +120,16 @@ export function DataViewInfiniteLoadProvider({ } }, [hasNext, isPageLoaded, offset, requestKey, queryKey, pages, items, next]) + // The view refetches only its current page, so a reload starts the list again from the first one. + const reload = useCallback((): void => { + first() + reloadPage() + }, [first, reloadPage]) + const shownItems = useMemo(() => joinPages(pages, items), [pages, items]) const dataViewValue = useMemo( - (): DataViewContextValue => ({ ...dataView, items: shownItems, itemCount: shownItems.length }), - [dataView, shownItems], + (): DataViewContextValue => ({ ...dataView, items: shownItems, itemCount: shownItems.length, reload }), + [dataView, shownItems, reload], ) const contextValue = useMemo((): InfiniteLoadContextValue => ({ diff --git a/tests/react/dataview/infiniteLoad.test.tsx b/tests/react/dataview/infiniteLoad.test.tsx index e123a80e..a7ab59d3 100644 --- a/tests/react/dataview/infiniteLoad.test.tsx +++ b/tests/react/dataview/infiniteLoad.test.tsx @@ -13,6 +13,7 @@ import { Select, SelectDataView, useDataViewContext, + useDataViewReload, } from '@contember/bindx-dataview' import { getByTestId, queryByTestId } from './helpers.js' @@ -64,6 +65,11 @@ function SearchButton(): ReactElement { ) } +function ReloadButton(): ReactElement { + const reload = useDataViewReload() + return +} + function renderPicker(adapter: MockAdapter): HTMLElement { function Picker(): ReactElement { const article = useEntity(articleDef, { by: { id: 'a1' } }, e => e.id().tag(t => t.id().name())) @@ -79,6 +85,7 @@ function renderPicker(adapter: MockAdapter): HTMLElement { + @@ -152,4 +159,19 @@ describe('DataViewInfiniteLoadProvider', () => { }) await waitFor(() => expect(optionIds(container)).toEqual(Array.from({ length: 10 }, (_, i) => tagId((i + 1) * 10)))) }) + + test('reload() loads the list again from the first page', async () => { + const adapter = createAdapter() + const container = renderPicker(adapter) + await waitFor(() => expect(optionIds(container)).toHaveLength(50)) + await loadMore(container) + await waitFor(() => expect(optionIds(container)).toHaveLength(100)) + + delete adapter.getStore()['Tag']?.[tagId(1)] + await act(async () => { + fireEvent.click(getByTestId(container, 'reload')) + }) + + await waitFor(() => expect(optionIds(container)).toEqual(expectedIds(51).slice(1))) + }) }) From eb4eeb9ee6a69afa4ba7a036051c88ed57ba3ef9 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 17:44:51 +0100 Subject: [PATCH 06/10] fix(bindx-dataview): report refreshing only while earlier rows are on screen The loader state reported `refreshing` both while stale rows were shown and while a view loaded with nothing to show. So DataViewEmpty, which waits for `loaded`, hid an empty grid's "No results" on every refetch, and letting it render on `refreshing` would have flashed it during a load with no result yet. - `refreshing` now means the rows on screen answer an earlier query; a load with nothing to show is `initial`. - DataViewEmpty, like DataViewNonEmpty, stays on screen while refreshing. - HasManyDataGrid keeps the previous rows for any refetch (page, sort, filter, reload) instead of clearing them, and derives the refetch in render from the request key, so its loader state never lags behind the rows by a render. Refs #124 Co-Authored-By: Claude Opus 5.5 --- packages/bindx-dataview/src/DataGrid.tsx | 4 +- .../bindx-dataview/src/HasManyDataGrid.tsx | 22 ++++--- .../bindx-dataview/src/loaderComponents.tsx | 8 ++- packages/bindx-dataview/src/loaderState.ts | 14 +++++ .../src/select/SelectDataView.tsx | 4 +- packages/bindx-dataview/src/useLoaderState.ts | 18 ------ tests/react/dataview/dataViewReload.test.tsx | 61 +++++++++++++++++++ 7 files changed, 96 insertions(+), 35 deletions(-) create mode 100644 packages/bindx-dataview/src/loaderState.ts delete mode 100644 packages/bindx-dataview/src/useLoaderState.ts diff --git a/packages/bindx-dataview/src/DataGrid.tsx b/packages/bindx-dataview/src/DataGrid.tsx index 206bc4b1..d0d4938e 100644 --- a/packages/bindx-dataview/src/DataGrid.tsx +++ b/packages/bindx-dataview/src/DataGrid.tsx @@ -22,7 +22,7 @@ import { } from '@contember/bindx-react' import { useDataViewKey } from './DataViewKeyProvider.js' import { DataViewProvider, type DataViewContextValue } from './DataViewContext.js' -import { useLoaderState } from './useLoaderState.js' +import { resolveLoaderState } from './loaderState.js' import { useDataGridSetup, QUERY_FILTER_NAME } from './useDataGridSetup.js' import { useListFetchAllData } from './useListFetchAllData.js' import { usePagingTotalCount } from './usePagingTotalCount.js' @@ -116,7 +116,7 @@ function DataGridImpl>({ const items = result.$status === 'ready' ? result.items : [] const itemCount = items.length const isRefetching = result.$status === 'ready' && result.$isRefetching - const loaderState = useLoaderState(result.$status, isRefetching) + const loaderState = resolveLoaderState(result.$status, isRefetching) // ---- Total count via a standalone count query (keyed on filter only) ---- const { count: totalCount } = useEntityCount(entity, { diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index 54b359d4..f09e46fc 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -40,7 +40,7 @@ import { } from '@contember/bindx-react' import { useDataViewKey } from './DataViewKeyProvider.js' import { DataViewProvider, type DataViewContextValue, type DataViewFetchAllData } from './DataViewContext.js' -import { useLoaderState } from './useLoaderState.js' +import { resolveLoaderState } from './loaderState.js' import { useDataGridSetup } from './useDataGridSetup.js' import { buildHasManyRelationQuery, extractHasManyRelationRows } from './hasManyRelationQuery.js' import { usePagingTotalCount } from './usePagingTotalCount.js' @@ -84,8 +84,8 @@ interface ListState { totalCount?: number /** The query options the items were loaded for. */ optionsKey?: string - /** The items are shown while the same query loads again. */ - isRefetching?: boolean + /** The request (query options and reload) the items were loaded for. */ + requestKey?: string } const INITIAL_LIST_STATE: ListState = { status: 'loading', items: [] } @@ -159,12 +159,13 @@ function HasManyDataGridImpl({ setReloadCounter(c => c + 1) }, []) + const requestKey = JSON.stringify([optionsKey, reloadCounter]) + // ---- Data loading via GetQuery on parent ---- useEffect(() => { const abortController = new AbortController() - setListState(previous => previous.status === 'ready' && previous.optionsKey === optionsKey - ? { ...previous, isRefetching: true } - : INITIAL_LIST_STATE) + // Stale-while-revalidate: the rows of the previous request stay on screen until the new ones land. + setListState(previous => previous.status === 'ready' ? previous : INITIAL_LIST_STATE) const fetchData = async (): Promise => { try { @@ -197,7 +198,7 @@ function HasManyDataGridImpl({ const relation = extractHasManyRelationRows(result.data, { alias, fieldName }) if (!relation) { - setListState({ status: 'ready', items: [], optionsKey }) + setListState({ status: 'ready', items: [], optionsKey, requestKey }) return } @@ -209,7 +210,7 @@ function HasManyDataGridImpl({ } }) - setListState({ status: 'ready', items, totalCount: relation.totalCount, optionsKey }) + setListState({ status: 'ready', items, totalCount: relation.totalCount, optionsKey, requestKey }) } catch (error) { if (abortController.signal.aborted) return setListState({ status: 'error', items: [] }) @@ -221,7 +222,7 @@ function HasManyDataGridImpl({ return () => { abortController.abort() } - }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, reloadCounter, setup.selection, batcher, dispatcher, store, schemaRegistry]) + }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, requestKey, setup.selection, batcher, dispatcher, store, schemaRegistry]) // ---- Build items from state ---- const items = useMemo((): EntityAccessor[] => { @@ -239,7 +240,8 @@ function HasManyDataGridImpl({ const itemCount = items.length - const loaderState = useLoaderState(listState.status, listState.isRefetching ?? false) + // Derived in render: on the render that changes the request, the rows already answer an earlier one. + const loaderState = resolveLoaderState(listState.status, listState.requestKey !== requestKey) const countKey = useMemo( () => JSON.stringify([parentEntityType, parentEntityId, fieldName, setup.combinedFilter ?? {}]), diff --git a/packages/bindx-dataview/src/loaderComponents.tsx b/packages/bindx-dataview/src/loaderComponents.tsx index da185e7b..691689f2 100644 --- a/packages/bindx-dataview/src/loaderComponents.tsx +++ b/packages/bindx-dataview/src/loaderComponents.tsx @@ -136,7 +136,9 @@ export interface DataViewEmptyProps { export function DataViewEmpty({ children }: DataViewEmptyProps): ReactElement { const { loaderState, itemCount } = useDataViewContext() - if (loaderState !== 'loaded' || itemCount > 0) return <>{null} + // An empty result stays on screen while the current query loads. + const isShowingResult = loaderState === 'loaded' || loaderState === 'refreshing' + if (!isShowingResult || itemCount > 0) return <>{null} return <>{children} } @@ -147,8 +149,8 @@ export interface DataViewNonEmptyProps { export function DataViewNonEmpty({ children }: DataViewNonEmptyProps): ReactElement { const { loaderState, itemCount } = useDataViewContext() // Rows of an earlier query stay mounted while the current one loads. - const isShowingRows = loaderState === 'loaded' || loaderState === 'refreshing' - if (!isShowingRows || itemCount === 0) return <>{null} + const isShowingResult = loaderState === 'loaded' || loaderState === 'refreshing' + if (!isShowingResult || itemCount === 0) return <>{null} return <>{children} } diff --git a/packages/bindx-dataview/src/loaderState.ts b/packages/bindx-dataview/src/loaderState.ts new file mode 100644 index 00000000..e6d49cbd --- /dev/null +++ b/packages/bindx-dataview/src/loaderState.ts @@ -0,0 +1,14 @@ +import type { DataViewLoaderState } from './DataViewContext.js' + +export type ListLoadStatus = 'loading' | 'error' | 'ready' + +/** + * Derives the loader state in render, so it always describes the rows rendered with it. + * `refreshing` means the rows on screen answer an earlier query while the current one loads; + * a load with no rows to show is `initial`. + */ +export function resolveLoaderState(status: ListLoadStatus, isRefetching: boolean): DataViewLoaderState { + if (status === 'ready') return isRefetching ? 'refreshing' : 'loaded' + if (status === 'error') return 'failed' + return 'initial' +} diff --git a/packages/bindx-dataview/src/select/SelectDataView.tsx b/packages/bindx-dataview/src/select/SelectDataView.tsx index 107c0549..a18389f4 100644 --- a/packages/bindx-dataview/src/select/SelectDataView.tsx +++ b/packages/bindx-dataview/src/select/SelectDataView.tsx @@ -44,7 +44,7 @@ import { } from '@contember/bindx-react' import { useSelectOptions } from './selectContext.js' import { DataViewProvider, type DataViewContextValue } from '../DataViewContext.js' -import { useLoaderState } from '../useLoaderState.js' +import { resolveLoaderState } from '../loaderState.js' import { useFilteringState, useSortingState, usePagingState, useSelectionState } from '../useDataViewState.js' import { useListFetchAllData } from '../useListFetchAllData.js' import { usePagingTotalCount } from '../usePagingTotalCount.js' @@ -181,7 +181,7 @@ function SelectDataViewImpl({ const items = result.$status === 'ready' ? result.items : [] const itemCount = items.length const isRefetching = result.$status === 'ready' && result.$isRefetching - const loaderState = useLoaderState(result.$status, isRefetching) + const loaderState = resolveLoaderState(result.$status, isRefetching) // ---- Total count via a standalone count query (keyed on filter only) ---- const { count: totalCount } = useEntityCount(options, { diff --git a/packages/bindx-dataview/src/useLoaderState.ts b/packages/bindx-dataview/src/useLoaderState.ts deleted file mode 100644 index c7170099..00000000 --- a/packages/bindx-dataview/src/useLoaderState.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { useRef } from 'react' -import type { DataViewLoaderState } from './DataViewContext.js' - -export type ListLoadStatus = 'loading' | 'error' | 'ready' - -/** - * Derives the loader state in render, so it always describes the rows rendered with it. - * `isRefetching`: the rows on screen answer an earlier query while the current one loads. - */ -export function useLoaderState(status: ListLoadStatus, isRefetching: boolean): DataViewLoaderState { - const hasLoadedOnce = useRef(false) - if (status === 'ready') { - hasLoadedOnce.current = true - return isRefetching ? 'refreshing' : 'loaded' - } - if (status === 'error') return 'failed' - return hasLoadedOnce.current ? 'refreshing' : 'initial' -} diff --git a/tests/react/dataview/dataViewReload.test.tsx b/tests/react/dataview/dataViewReload.test.tsx index 37176742..00f21660 100644 --- a/tests/react/dataview/dataViewReload.test.tsx +++ b/tests/react/dataview/dataViewReload.test.tsx @@ -19,6 +19,7 @@ import { DataGrid, DataGridTextColumn, DataViewEachRow, + DataViewEmpty, DataViewNonEmpty, HasManyDataGrid, Select, @@ -218,4 +219,64 @@ describe('data view reload()', () => { expect(rowCount()).toBe(3) await waitFor(() => expect(rowCount()).toBe(2)) }) + + test('an empty result stays on screen while the grid refetches', async () => { + const adapter = createAdapter() + const { container } = render( + + + {it => ( + <> + + + + + )} + + , + ) + await waitFor(() => expect(queryByTestId(container, 'no-results')).not.toBeNull()) + + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(queryByTestId(container, 'no-results')).not.toBeNull() + await waitFor(() => expect(getByTestId(container, 'loader-state').textContent).toBe('loaded')) + expect(queryByTestId(container, 'no-results')).not.toBeNull() + }) + + test('HasManyDataGrid keeps the previous page on screen while the next one loads', async () => { + const adapter = createAdapter() + const { container } = render( + + + {project => ( + + {it => ( + <> + + + {item => } + + + + + )} + + )} + + , + ) + const rowIds = (): (string | null)[] => Array.from(container.querySelectorAll('[data-testid="row"]')).map(el => el.getAttribute('data-id')) + await waitFor(() => expect(rowIds()).toEqual(['t1', 't2'])) + + await act(async () => { + fireEvent.click(getByTestId(container, 'datagrid-pagination-next')) + }) + + expect(getByTestId(container, 'loader-state').textContent).toBe('refreshing') + expect(rowIds()).toEqual(['t1', 't2']) + await waitFor(() => expect(rowIds()).toEqual(['t3'])) + expect(getByTestId(container, 'loader-state').textContent).toBe('loaded') + }) }) From 3dd3746fe9aaf38b5ef9f7ee5709a7158c9a2bef Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 17:44:51 +0100 Subject: [PATCH 07/10] fix(bindx-dataview): keep an infinite list on screen while it reloads A reload moved the view to the first page while it still held the rows of the last offset, so the list showed only its last page until the first one landed. The provider now keeps the list shown at the reload until the first page lands (or fails), and drops its stored pages. Documents that only the provider's descendants get its reload. Refs #146 Co-Authored-By: Claude Opus 5.5 --- packages/bindx-dataview/src/infiniteLoad.tsx | 17 ++++++++++++++--- tests/react/dataview/infiniteLoad.test.tsx | 2 ++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/bindx-dataview/src/infiniteLoad.tsx b/packages/bindx-dataview/src/infiniteLoad.tsx index a44c9a36..5c9efda0 100644 --- a/packages/bindx-dataview/src/infiniteLoad.tsx +++ b/packages/bindx-dataview/src/infiniteLoad.tsx @@ -82,7 +82,8 @@ export interface DataViewInfiniteLoadProviderProps { /** * Shows the pages loaded so far as one list: its children see the stored pages plus the current one * as the data view's `items`. `loadMore` stores the current page and moves the view to the next one; - * `reload` loads the list again from the first page. + * `reload` loads the list again from the first page. Only descendants get that `reload`: one above + * the provider refetches the view's current page alone. */ export function DataViewInfiniteLoadProvider({ children, @@ -120,13 +121,23 @@ export function DataViewInfiniteLoadProvider({ } }, [hasNext, isPageLoaded, offset, requestKey, queryKey, pages, items, next]) + // The list shown when a reload started stays on screen until the first page lands. + const [reloadedList, setReloadedList] = useState(null) + if (reloadedList !== null && !isLoadingMore) { + setReloadedList(null) + } + + const joinedItems = useMemo(() => joinPages(pages, items), [pages, items]) + const shownItems = reloadedList ?? joinedItems + // The view refetches only its current page, so a reload starts the list again from the first one. const reload = useCallback((): void => { + setReloadedList(joinedItems) + setStored({ queryKey, pages: [] }) first() reloadPage() - }, [first, reloadPage]) + }, [joinedItems, queryKey, first, reloadPage]) - const shownItems = useMemo(() => joinPages(pages, items), [pages, items]) const dataViewValue = useMemo( (): DataViewContextValue => ({ ...dataView, items: shownItems, itemCount: shownItems.length, reload }), [dataView, shownItems, reload], diff --git a/tests/react/dataview/infiniteLoad.test.tsx b/tests/react/dataview/infiniteLoad.test.tsx index a7ab59d3..c8fea83c 100644 --- a/tests/react/dataview/infiniteLoad.test.tsx +++ b/tests/react/dataview/infiniteLoad.test.tsx @@ -172,6 +172,8 @@ describe('DataViewInfiniteLoadProvider', () => { fireEvent.click(getByTestId(container, 'reload')) }) + // The list stays as it was until the first page lands. + expect(optionIds(container)).toEqual(expectedIds(100)) await waitFor(() => expect(optionIds(container)).toEqual(expectedIds(51).slice(1))) }) }) From 844bde1b7c3612b21099edffd09d09bc335f8ad0 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 18:15:46 +0100 Subject: [PATCH 08/10] fix(bindx-dataview): scope HasManyDataGrid's stale rows to their relation HasManyDataGrid keeps the previous rows on screen while a refetch loads, but its request key covered only the query options. When the parent changed (p1 -> p2 -> p1 with the grid kept mounted), the rows of the other parent were shown as `loaded`, and their count fed the current parent's total. - Rows are kept only for the relation (parent, field, selection) they were loaded for; for another one the grid loads as `initial`. The same check gates the page that feeds the total. - The data effect follows the request key, which covers the selection's content, instead of the selection object, which is new on every parent render and refetched the grid each time. - Responses are applied with refreshServerData, as useEntityList does, so a refetch no longer overwrites edits made to rows still on screen. Refs #124 Co-Authored-By: Claude Opus 5.5 --- .../bindx-dataview/src/HasManyDataGrid.tsx | 47 +++-- .../hasManyDataGridStaleRows.test.tsx | 195 ++++++++++++++++++ 2 files changed, 224 insertions(+), 18 deletions(-) create mode 100644 tests/react/dataview/hasManyDataGridStaleRows.test.tsx diff --git a/packages/bindx-dataview/src/HasManyDataGrid.tsx b/packages/bindx-dataview/src/HasManyDataGrid.tsx index f09e46fc..b0771ba5 100644 --- a/packages/bindx-dataview/src/HasManyDataGrid.tsx +++ b/packages/bindx-dataview/src/HasManyDataGrid.tsx @@ -19,7 +19,7 @@ * ``` */ -import { memo, type ReactElement, type ReactNode, useEffect, useMemo, useCallback, useState } from 'react' +import { memo, type ReactElement, type ReactNode, useEffect, useMemo, useCallback, useRef, useState } from 'react' import type { EntityAccessor, HasManyRef, @@ -31,7 +31,7 @@ import { FIELD_REF_META } from '@contember/bindx' import { buildQueryFromSelection, EntityHandle, - setEntityData, + refreshServerData, } from '@contember/bindx' import type { SchemaRegistry } from '@contember/bindx' import type { StateStorageOrName } from './stateStorage.js' @@ -82,6 +82,8 @@ interface ListState { status: 'loading' | 'error' | 'ready' items: Array<{ id: string; data: object }> totalCount?: number + /** The relation (parent, field, selection) the items were loaded for. */ + scopeKey?: string /** The query options the items were loaded for. */ optionsKey?: string /** The request (query options and reload) the items were loaded for. */ @@ -159,13 +161,18 @@ function HasManyDataGridImpl({ setReloadCounter(c => c + 1) }, []) - const requestKey = JSON.stringify([optionsKey, reloadCounter]) + const scopeKey = JSON.stringify([parentEntityType, parentEntityId, fieldName, targetEntityType, setup.queryKey]) + const requestKey = JSON.stringify([scopeKey, optionsKey, reloadCounter]) + // The request key covers the selection's content; the effect reads the latest object without refetching on a new identity. + const selectionRef = useRef(setup.selection) + selectionRef.current = setup.selection // ---- Data loading via GetQuery on parent ---- useEffect(() => { const abortController = new AbortController() + const selection = selectionRef.current // Stale-while-revalidate: the rows of the previous request stay on screen until the new ones land. - setListState(previous => previous.status === 'ready' ? previous : INITIAL_LIST_STATE) + setListState(previous => previous.status === 'ready' && previous.scopeKey === scopeKey ? previous : INITIAL_LIST_STATE) const fetchData = async (): Promise => { try { @@ -184,7 +191,7 @@ function HasManyDataGridImpl({ orderBy: currentOptions.orderBy, limit: currentOptions.limit, offset: currentOptions.offset, - targetSpec: buildQueryFromSelection(setup.selection), + targetSpec: buildQueryFromSelection(selection), }) const result = await batcher.enqueue(query, { signal: abortController.signal }) @@ -192,28 +199,29 @@ function HasManyDataGridImpl({ if (abortController.signal.aborted) return if (result.type !== 'get' || !result.data) { - setListState({ status: 'error', items: [] }) + setListState({ status: 'error', items: [], scopeKey }) return } const relation = extractHasManyRelationRows(result.data, { alias, fieldName }) if (!relation) { - setListState({ status: 'ready', items: [], optionsKey, requestKey }) + setListState({ status: 'ready', items: [], scopeKey, optionsKey, requestKey }) return } - store.indexServerResponse(targetEntityType, relation.rows, setup.selection, schemaRegistry) + store.indexServerResponse(targetEntityType, relation.rows, selection, schemaRegistry) const items = relation.rows.map(data => ({ id: getRowId(targetEntityType, data), data: store.resolveServerOccurrence(data) })) store.batchNotifications(() => { for (const item of items) { - dispatcher.dispatch(setEntityData(targetEntityType, item.id, item.data, true)) + // Revalidation preserves local edits while advancing the server baseline. + dispatcher.dispatch(refreshServerData(targetEntityType, item.id, item.data)) } }) - setListState({ status: 'ready', items, totalCount: relation.totalCount, optionsKey, requestKey }) + setListState({ status: 'ready', items, totalCount: relation.totalCount, scopeKey, optionsKey, requestKey }) } catch (error) { if (abortController.signal.aborted) return - setListState({ status: 'error', items: [] }) + setListState({ status: 'error', items: [], scopeKey }) } } @@ -222,12 +230,15 @@ function HasManyDataGridImpl({ return () => { abortController.abort() } - }, [parentEntityType, parentEntityId, fieldName, targetEntityType, optionsKey, requestKey, setup.selection, batcher, dispatcher, store, schemaRegistry]) + }, [parentEntityType, parentEntityId, fieldName, targetEntityType, scopeKey, optionsKey, requestKey, batcher, dispatcher, store, schemaRegistry]) + + // Rows loaded for another relation (a different parent) are never shown, not even as stale. + const shownState = listState.scopeKey === scopeKey ? listState : INITIAL_LIST_STATE // ---- Build items from state ---- const items = useMemo((): EntityAccessor[] => { - if (listState.status !== 'ready') return [] - return listState.items.map((item) => + if (shownState.status !== 'ready') return [] + return shownState.items.map((item) => EntityHandle.create( item.id, targetEntityType, @@ -236,21 +247,21 @@ function HasManyDataGridImpl({ schemaRegistry as SchemaRegistry>, ) as unknown as EntityAccessor, ) - }, [listState, targetEntityType, store, dispatcher, schemaRegistry]) + }, [shownState, targetEntityType, store, dispatcher, schemaRegistry]) const itemCount = items.length // Derived in render: on the render that changes the request, the rows already answer an earlier one. - const loaderState = resolveLoaderState(listState.status, listState.requestKey !== requestKey) + const loaderState = resolveLoaderState(shownState.status, shownState.requestKey !== requestKey) const countKey = useMemo( () => JSON.stringify([parentEntityType, parentEntityId, fieldName, setup.combinedFilter ?? {}]), [parentEntityType, parentEntityId, fieldName, setup.combinedFilter], ) usePagingTotalCount(setup.paging, { - countedTotal: listState.totalCount ?? null, + countedTotal: shownState.totalCount ?? null, countKey, - isPageCurrent: listState.status === 'ready' && listState.optionsKey === optionsKey, + isPageCurrent: shownState.status === 'ready' && shownState.optionsKey === optionsKey, pageItemCount: itemCount, }) diff --git a/tests/react/dataview/hasManyDataGridStaleRows.test.tsx b/tests/react/dataview/hasManyDataGridStaleRows.test.tsx new file mode 100644 index 00000000..3a2eaa77 --- /dev/null +++ b/tests/react/dataview/hasManyDataGridStaleRows.test.tsx @@ -0,0 +1,195 @@ +// HasManyDataGrid keeps the previous rows on screen while a refetch loads (stale-while-revalidate). +// Its loader state must describe the rows rendered with it on every render, rows of another +// parent must never show, and a refetch must not overwrite edits made to the rows meanwhile. +import '../../setup' +import { afterEach, describe, expect, test } from 'bun:test' +import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' +import React, { useState, type ReactElement } from 'react' +import { + BindxProvider, + Entity, + MockAdapter, + defineSchema, + entityDef, + hasMany, + scalar, + useEntity, +} from '@contember/bindx-react' +import type { DataViewLoaderStateType } from '@contember/bindx-dataview' +import { + DataGridTextColumn, + DataViewEachRow, + HasManyDataGrid, + useDataViewContext, + useDataViewReload, +} from '@contember/bindx-dataview' +import { TestPagination, getByTestId } from './helpers.js' + +afterEach(cleanup) + +interface Task { + id: string + title: string +} + +interface Project { + id: string + tasks: Task[] +} + +const schema = defineSchema<{ Task: Task; Project: Project }>({ + entities: { + Task: { fields: { id: scalar(), title: scalar() } }, + Project: { fields: { id: scalar(), tasks: hasMany('Task') } }, + }, +}) +const taskDef = entityDef('Task') +const projectDef = entityDef('Project') + +const PROJECT_TASKS: Record = { + p1: ['t1', 't2', 't3'], + p2: ['t4'], +} + +function createAdapter(): MockAdapter { + const task = (id: string): Record => ({ id, title: `Task ${id}` }) + return new MockAdapter({ + Task: Object.fromEntries(['t1', 't2', 't3', 't4'].map(id => [id, task(id)])), + Project: Object.fromEntries(Object.entries(PROJECT_TASKS).map(([id, tasks]) => [id, { id, tasks: tasks.map(task) }])), + }, { delay: 20 }) +} + +interface RenderRecord { + readonly projectId: string + readonly pageIndex: number + readonly loaderState: DataViewLoaderStateType + readonly rowIds: readonly string[] +} + +/** Records what every render of the grid's children shows. */ +function Recorder({ projectId, records }: { projectId: string; records: RenderRecord[] }): ReactElement { + const { loaderState, items, paging } = useDataViewContext() + const reload = useDataViewReload() + records.push({ projectId, pageIndex: paging.state.pageIndex, loaderState, rowIds: items.map(item => item.id) }) + return ( + <> + {loaderState} + + + ) +} + +function ProjectTasks({ projectId, records, itemsPerPage }: { projectId: string; records: RenderRecord[]; itemsPerPage?: number }): ReactElement { + return ( + + {project => ( + + {it => ( + <> + + {item => } + + + + )} + + )} + + ) +} + +const rowIds = (container: HTMLElement): (string | null)[] => + Array.from(container.querySelectorAll('[data-testid="row"]')).map(el => el.getAttribute('data-id')) + +async function click(container: HTMLElement, testId: string): Promise { + await act(async () => { + fireEvent.click(getByTestId(container, testId)) + }) +} + +describe('HasManyDataGrid stale rows', () => { + test('every render that reads loaded shows the rows of the current page', async () => { + const records: RenderRecord[] = [] + const { container } = render( + + + , + ) + await waitFor(() => expect(rowIds(container)).toEqual(['t1', 't2'])) + + await click(container, 'datagrid-pagination-next') + await waitFor(() => expect(rowIds(container)).toEqual(['t3'])) + + const pages = [['t1', 't2'], ['t3']] + const loaded = records.filter(record => record.loaderState === 'loaded') + expect(loaded.length).toBeGreaterThan(0) + for (const record of loaded) { + expect(record.rowIds).toEqual(pages[record.pageIndex] ?? []) + } + expect(records.some(record => record.loaderState === 'refreshing' && record.pageIndex === 1 && record.rowIds.join() === 't1,t2')).toBe(true) + }) + + test('rows of another parent are never shown', async () => { + const records: RenderRecord[] = [] + + function Switcher(): ReactElement { + const [projectId, setProjectId] = useState('p1') + return ( + <> + + + + + ) + } + + const { container } = render( + + + , + ) + await waitFor(() => expect(rowIds(container)).toEqual(['t1', 't2', 't3'])) + await click(container, 'p2') + await waitFor(() => expect(rowIds(container)).toEqual(['t4'])) + await click(container, 'p1') + await waitFor(() => expect(rowIds(container)).toEqual(['t1', 't2', 't3'])) + await waitFor(() => expect(getByTestId(container, 'loader-state').textContent).toBe('loaded')) + + for (const record of records) { + const ownTasks = PROJECT_TASKS[record.projectId] ?? [] + for (const id of record.rowIds) { + expect(ownTasks).toContain(id) + } + } + }) + + test('a refetch keeps edits made to the rows', async () => { + const records: RenderRecord[] = [] + + function TitleEditor(): ReactElement { + const task = useEntity(taskDef, { by: { id: 't1' } }, e => e.title()) + if (task.$status !== 'ready') return + return ( + <> + {task.title.value} + + + ) + } + + const { container } = render( + + + + , + ) + await waitFor(() => expect(rowIds(container)).toEqual(['t1', 't2', 't3'])) + await waitFor(() => expect(getByTestId(container, 'title').textContent).toBe('Task t1')) + + await click(container, 'edit') + await click(container, 'reload') + await waitFor(() => expect(getByTestId(container, 'loader-state').textContent).toBe('loaded')) + + expect(getByTestId(container, 'title').textContent).toBe('Edited') + }) +}) From abcc609a9e60fe98d18140bf57ad54682042ee8c Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 18:15:46 +0100 Subject: [PATCH 09/10] fix(bindx-dataview): keep the first snapshot on a reload during a reload A second reload() before the first page landed snapshotted the joined list, which by then held only the stale current page, so the infinite list shrank to one page until the load finished. The list shown at the first reload is kept. Refs #146 Co-Authored-By: Claude Opus 5.5 --- packages/bindx-dataview/src/infiniteLoad.tsx | 3 ++- tests/react/dataview/infiniteLoad.test.tsx | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/bindx-dataview/src/infiniteLoad.tsx b/packages/bindx-dataview/src/infiniteLoad.tsx index 5c9efda0..5b47be30 100644 --- a/packages/bindx-dataview/src/infiniteLoad.tsx +++ b/packages/bindx-dataview/src/infiniteLoad.tsx @@ -132,7 +132,8 @@ export function DataViewInfiniteLoadProvider({ // The view refetches only its current page, so a reload starts the list again from the first one. const reload = useCallback((): void => { - setReloadedList(joinedItems) + // A reload during a reload keeps the list shown at the first one. + setReloadedList(current => current ?? joinedItems) setStored({ queryKey, pages: [] }) first() reloadPage() diff --git a/tests/react/dataview/infiniteLoad.test.tsx b/tests/react/dataview/infiniteLoad.test.tsx index c8fea83c..3f429234 100644 --- a/tests/react/dataview/infiniteLoad.test.tsx +++ b/tests/react/dataview/infiniteLoad.test.tsx @@ -176,4 +176,21 @@ describe('DataViewInfiniteLoadProvider', () => { expect(optionIds(container)).toEqual(expectedIds(100)) await waitFor(() => expect(optionIds(container)).toEqual(expectedIds(51).slice(1))) }) + + test('a reload during a reload keeps the list shown at the first one', async () => { + const container = renderPicker(createAdapter()) + await waitFor(() => expect(optionIds(container)).toHaveLength(50)) + await loadMore(container) + await waitFor(() => expect(optionIds(container)).toHaveLength(100)) + + await act(async () => { + fireEvent.click(getByTestId(container, 'reload')) + }) + await act(async () => { + fireEvent.click(getByTestId(container, 'reload')) + }) + + expect(optionIds(container)).toEqual(expectedIds(100)) + await waitFor(() => expect(optionIds(container)).toEqual(expectedIds(50))) + }) }) From 31736ebdac8f19787c2ee52149b9e308f4fc1a09 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 4 Nov 2026 18:15:46 +0100 Subject: [PATCH 10/10] docs(bindx-dataview): describe what each loader state means `initial` is now any load with no rows to show, including a retry after a failure, and `refreshing` means the rows shown answer an earlier query. Documents the states on DataViewLoaderState and the loader state components, and covers the retry case with a test. Refs #124 Co-Authored-By: Claude Opus 5.5 --- .../bindx-dataview/src/DataViewContext.tsx | 6 +++ .../bindx-dataview/src/loaderComponents.tsx | 8 ++-- tests/react/dataview/dataViewReload.test.tsx | 44 +++++++++++++++++++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/bindx-dataview/src/DataViewContext.tsx b/packages/bindx-dataview/src/DataViewContext.tsx index c6517eb8..a70198ad 100644 --- a/packages/bindx-dataview/src/DataViewContext.tsx +++ b/packages/bindx-dataview/src/DataViewContext.tsx @@ -8,6 +8,12 @@ import type { FilteringState, SortingStateResult, PagingStateResult, SelectionSt import type { DataViewColumn } from './columnLeaf.js' import type { EntityAccessor, SelectionMeta } from '@contember/bindx' +/** + * - `initial`: loading, with no rows to show (first load, or a retry after a failure) + * - `loaded`: the rows answer the current query + * - `refreshing`: the rows shown answer an earlier query while the current one loads + * - `failed`: the current query failed + */ export type DataViewLoaderState = 'initial' | 'loaded' | 'refreshing' | 'failed' export type DataViewItem = EntityAccessor diff --git a/packages/bindx-dataview/src/loaderComponents.tsx b/packages/bindx-dataview/src/loaderComponents.tsx index 691689f2..6ad87a80 100644 --- a/packages/bindx-dataview/src/loaderComponents.tsx +++ b/packages/bindx-dataview/src/loaderComponents.tsx @@ -60,9 +60,9 @@ export interface DataViewLoaderStateProps { children: React.ReactNode /** Render children when data is loaded */ loaded?: boolean - /** Render children when refreshing (after initial load) */ + /** Render children while rows of an earlier query are shown and the current one loads */ refreshing?: boolean - /** Render children during initial load */ + /** Render children while loading with no rows to show */ initial?: boolean /** Render children on failure */ failed?: boolean @@ -82,9 +82,9 @@ export function DataViewLoaderState({ export interface DataViewLoaderStateSwitchProps { /** Render when data is loaded */ loaded?: React.ReactNode - /** Render when refreshing (after initial load) */ + /** Render while rows of an earlier query are shown and the current one loads */ refreshing?: React.ReactNode - /** Render during initial load */ + /** Render while loading with no rows to show */ initial?: React.ReactNode /** Render on failure */ failed?: React.ReactNode diff --git a/tests/react/dataview/dataViewReload.test.tsx b/tests/react/dataview/dataViewReload.test.tsx index 00f21660..d46942a4 100644 --- a/tests/react/dataview/dataViewReload.test.tsx +++ b/tests/react/dataview/dataViewReload.test.tsx @@ -27,6 +27,7 @@ import { useDataViewContext, useDataViewReload, } from '@contember/bindx-dataview' +import type { BackendAdapter, Query, QueryOptions, QueryResult } from '@contember/bindx' import { TestPagination, TestTable, getByTestId, getRowCount, queryByTestId } from './helpers.js' afterEach(cleanup) @@ -73,6 +74,25 @@ function finishTaskOnServer(adapter: MockAdapter, id: string): void { } } +/** Fails the first list query, then answers like the inner adapter. */ +class FailFirstListAdapter implements BackendAdapter { + private hasFailed = false + + constructor(private readonly inner: MockAdapter) {} + + async query(queries: readonly Query[], options?: QueryOptions): Promise { + if (!this.hasFailed && queries.some(query => query.type === 'list')) { + this.hasFailed = true + throw new Error('list query failed') + } + return this.inner.query(queries, options) + } + + persist(...args: Parameters): ReturnType { + return this.inner.persist(...args) + } +} + function ReloadProbe(): ReactElement { const reload = useDataViewReload() const { loaderState } = useDataViewContext() @@ -279,4 +299,28 @@ describe('data view reload()', () => { await waitFor(() => expect(rowIds()).toEqual(['t3'])) expect(getByTestId(container, 'loader-state').textContent).toBe('loaded') }) + + test('a retry after a failure loads as initial, with no rows to keep', async () => { + const adapter = new FailFirstListAdapter(createAdapter()) + const { container } = render( + + + {it => ( + <> + + + + + )} + + , + ) + await waitFor(() => expect(getByTestId(container, 'loader-state').textContent).toBe('failed')) + + await clickReload(container) + + expect(getByTestId(container, 'loader-state').textContent).toBe('initial') + await waitFor(() => expect(getRowCount(container)).toBe(3)) + expect(getByTestId(container, 'loader-state').textContent).toBe('loaded') + }) })