Skip to content

fix(bindx-dataview): keep the page index within the known page count - #132

Merged
matej21 merged 4 commits into
mainfrom
fix/paging-clamp-page-index
Sep 28, 2026
Merged

matej21 merged 4 commits into
mainfrom
fix/paging-clamp-page-index

Conversation

@matej21

@matej21 matej21 commented Sep 28, 2026 •

Copy link
Copy Markdown
Member

Closes #127.

The bug

usePagingState stored the requested page index and computed queryOffset from it without looking at the total. When the total shrank below the current page (a toolbar filter that narrows the result, or a page index restored from currentPageStateStorage that outlived its result set), the grid queried an offset past the last row and rendered its empty state under a non-zero count, with the paging UI reading "page 15 of 1".

The fix

  • The effective page index is the requested one clamped to [0, totalPages - 1] once the total is known. state.pageIndex, queryOffset, hasNext and hasPrevious all read the clamped value, so they agree on the first render after the count lands — there is no render with the out-of-range offset.
  • An effect writes the clamped value back through the stored-state setter, so the persisted index matches the page on screen and next() / previous() continue from it.
  • While the total is unknown the requested index is kept, so a restored page is not dropped before the count query answers.
  • A total of zero clamps to the first page (totalPages is at least 1).

Grids without a count

With an adapter that implements no count query (or a count query that fails), DataGrid, SelectDataView and HasManyDataGrid infer the total from a page shorter than the limit (queryOffset + itemCount). That inference used to run on the render that changed the offset, against the rows of the previous query. Combined with the clamp it cascaded: an empty page at offset 28 set a total of 26, the clamp moved to offset 26, the same stale rows set 24, and so on down to page 0 with a total of 0 — rows past the first page became unreachable.

  • useEntityList now reports $isRefetching from the render that changes the options or the queryKey, not only after its data-loading effect has run. A ready result never presents rows of an earlier query as current.
  • The three grids share usePagingTotalCount, which infers a total only from a page that answers the current query. HasManyDataGrid records the options its rows were loaded for.
  • Without a count, an empty page past offset 0 only bounds the total from above. usePagingTotalCount keeps the bounds the loaded pages prove — a full page raises the lower bound, an empty page lowers the upper one — and moves to the page holding the middle row, so the interval halves with every query. A page shorter than the limit, or bounds that meet, give the exact total. The bounds belong to one row set and restart when the filter changes.
  • The useEntity doc now says when its $isRefetching turns on: once the re-fetch starts, one render after the queryKey change. useEntityList reports it from the render that changes the query.

Behaviour to review

  • With a count query (the normal case), the count resolves the clamp in one step: a restored page 14 over 7 rows at 2 per page queries offset 28, then offset 6, and shows "Page 4 of 4".
  • Without a count query, the grid bisects between the proven bounds and stops on the last page with rows, with the exact total; every row stays reachable. The cost is logarithmic in the stored offset:
    • stored page 14 over 7 rows at 2 per page → offsets 28, 14, 6 → "Page 4 of 4", "7 total";
    • stored page 500 over the same rows → offsets 1000, 500, 250, 124, 62, 30, 14, 6 (8 queries);
    • no rows at all, stored page 14 → offsets 28, 14, 6, 2, 0 → "Page 1 of 1", "0 total";
    • a narrower filter on the last page (9 rows on page 5, filter to 3 rows) → offsets 8, 4, 2 → "Page 2 of 2", "3 total".
  • While the search runs, the paging UI shows the probed pages and the total it had before (unknown after a restore, the previous filter's total after a filter change).

Tests

  • tests/react/dataview/pagingPageIndexOutOfRange.test.tsx — hook level: the failing repro from the issue, the stored index written back, a stored index kept while the total is unknown, clamping to the last existing page and navigating from it, a total of zero. 4 of the 5 cases fail on main.
  • tests/react/dataview/dataGridPageIndexOutOfRange.test.tsx — DataGrid level, each asserting the exact list-query offsets:
    • a stored page 14 with a counting adapter and with an adapter that answers no count; both land on the last page with rows, and the count-less case also walks every page and reads all 7 rows;
    • a stored page 500 without a count, which must finish within 1 + ceil(log2(1000)) list queries;
    • an adapter with no rows and no count;
    • a narrower filter on the last page with a count-less adapter.
  • tests/react/dataview/hasManyDataGridPageIndexOutOfRange.test.tsx — HasManyDataGrid with a relation that reports no total: a stored page 14 over 7 rows ends on "Page 4 of 4" with offsets 28, 14, 6. It fails ("Page 2 of 2") without the optionsKey check.
  • tests/react/hooks/useEntityList/refetchingOnQueryChange.test.tsx — no render after an offset change reports the previous offset's rows as settled. Fails without the useEntityList change.

Checks:

  • bun run typecheck clean
  • bun run lint 0 errors
  • bun run test 2119 pass, 0 fail

🤖 Generated with Claude Code

https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5

matej21 and others added 3 commits September 28, 2026 11:07
…e total shrinks

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TPdGxo89UiPkZHxreAxvFD
usePagingState stored the requested page index and derived the query
offset from it without looking at the total. When the total shrank (a
narrower filter) or a stored index outlived its result set, the grid
queried an offset past the last row and rendered an empty page under a
non-zero count ("page 15 of 1").

The effective page index is now the requested one clamped to
[0, totalPages - 1] once the total is known, so state, queryOffset,
hasNext and hasPrevious agree on the first render after the count lands.
An effect writes the clamped value back so the stored index and the
relative navigation (next/previous) continue from the page on screen.
While the total is unknown the requested index is kept as is.

Closes #127

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
…query

Without a count (an adapter that implements no count query, or a failing
one), the grids infer the total from a page shorter than the limit. On the
render that changed the offset, that inference paired the new offset with
the rows of the previous query. With the page-index clamp this cascaded: an
empty page at offset 28 set a total of 26, the clamp moved to offset 26,
the same stale rows set 24, and so on down to page 0 with a total of 0.
Rows past the first page became unreachable.

- useEntityList reports $isRefetching from the render that changes the
  options or the queryKey, not only after its data-loading effect has run,
  so a ready result never presents rows of an earlier query as current.
- DataGrid, SelectDataView and HasManyDataGrid share usePagingTotalCount,
  which infers a total only from a page that answers the current query.
  HasManyDataGrid records the options its rows were loaded for.
- An empty page past offset 0 implies its offset as an upper bound, so the
  clamp steps back one page per query and stops on the last page with rows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
…unted

Without a count, an empty page past offset 0 only bounds the total from
above. Stepping back one page per query cost one round trip per page, so a
stored page 500 took 500 queries. usePagingTotalCount now keeps the bounds
the loaded pages prove (a full page raises the lower one, an empty page
lowers the upper one) and probes the page holding the middle row, so the
interval halves with every query. A short page, or bounds that meet, set
the exact total. The bounds belong to one row set and restart when the
filter changes.

Also covers HasManyDataGrid with a relation that reports no total, and
states in the useEntity doc when its $isRefetching turns on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ge3kRufpi9b9HMBSBjRze5
@matej21
matej21 merged commit 544abff into main Sep 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

usePagingState keeps a page index past the last page when the total count shrinks

1 participant