Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 8 additions & 11 deletions packages/bindx-dataview/src/DataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down Expand Up @@ -128,17 +129,13 @@ function DataGridImpl<TRoleMap extends Record<string, object>>({
}
}, [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])
const countKey = useMemo(() => JSON.stringify(setup.combinedFilter ?? {}), [setup.combinedFilter])
usePagingTotalCount(setup.paging, {
countedTotal: totalCount,
countKey,
isPageCurrent: result.$status === 'ready' && !result.$isRefetching,
pageItemCount: itemCount,
})

// ---- Reload ----
const [, setReloadCounter] = useState(0)
Expand Down
25 changes: 15 additions & 10 deletions packages/bindx-dataview/src/HasManyDataGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: [] }
Expand Down Expand Up @@ -185,7 +188,7 @@ function HasManyDataGridImpl<TEntity extends object>({

const relation = extractHasManyRelationRows(result.data, { alias, fieldName })
if (!relation) {
setListState({ status: 'ready', items: [] })
setListState({ status: 'ready', items: [], optionsKey })
return
}

Expand All @@ -196,7 +199,7 @@ function HasManyDataGridImpl<TEntity extends object>({
}
})

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: [] })
Expand Down Expand Up @@ -238,14 +241,16 @@ function HasManyDataGridImpl<TEntity extends object>({
}
}, [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])
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 ----
const [, setReloadCounter] = useState(0)
Expand Down
17 changes: 8 additions & 9 deletions packages/bindx-dataview/src/select/SelectDataView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -185,15 +186,13 @@ 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])
const countKey = useMemo(() => JSON.stringify(combinedFilter ?? {}), [combinedFilter])
usePagingTotalCount(paging, {
countedTotal: totalCount,
countKey,
isPageCurrent: result.$status === 'ready' && !result.$isRefetching,
pageItemCount: itemCount,
})

// ---- Reload ----
const [, setReloadCounter] = useState(0)
Expand Down
22 changes: 15 additions & 7 deletions packages/bindx-dataview/src/useDataViewState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -304,7 +304,7 @@ export function usePagingState(options: UsePagingOptions = {}): PagingStateResul
storageKey = 'dataview',
} = options

const [pageIndex, setPageIndex] = useStoredState<number>(
const [requestedPageIndex, setPageIndex] = useStoredState<number>(
currentPageStateStorage,
[storageKey, 'pageIndex'],
(stored) => stored ?? 0,
Expand All @@ -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<number | null>(null)
const [refreshCounter, setRefreshCounter] = useState(0)

Expand All @@ -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,
Expand Down
90 changes: 90 additions & 0 deletions packages/bindx-dataview/src/usePagingTotalCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { useEffect, useRef } from 'react'
import type { PagingStateResult } from './useDataViewState.js'

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 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.
*/
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 (!isPageCurrent || queryLimit === undefined || queryOffset === undefined) {
return
}

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) }
}
6 changes: 4 additions & 2 deletions packages/bindx-react/src/hooks/useEntity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.).
Expand Down
30 changes: 21 additions & 9 deletions packages/bindx-react/src/hooks/useEntityList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -350,14 +358,17 @@ 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 (
cache &&
cache.version === version &&
cache.storeVersion === storeVersion &&
cache.status === state.status &&
cache.isRefetching === state.isRefetching &&
cache.isRefetching === isRefetching &&
cache.accessorCache === itemAccessorCache
) {
return cache.result
Expand All @@ -382,7 +393,7 @@ export function useEntityList(
result = {
$status: 'ready',
$isLoading: false,
$isRefetching: state.isRefetching,
$isRefetching: isRefetching,
$isError: false,
$error: null,
$isDirty: false,
Expand All @@ -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<any>, b: UseEntityListResult<any>): boolean => {
Expand All @@ -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()
Expand Down Expand Up @@ -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()
})
Expand All @@ -481,6 +492,7 @@ export function useEntityList(
items: [],
error: createLoadError(normalizedError),
isRefetching: false,
requestKey,
}
versionRef.current++
store.notify()
Expand All @@ -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
}
Loading
Loading