feat(hooks): add usePaginatedQuery built on useOptimisticData#367
Open
JuliobaCR wants to merge 1 commit into
Open
feat(hooks): add usePaginatedQuery built on useOptimisticData#367JuliobaCR wants to merge 1 commit into
JuliobaCR wants to merge 1 commit into
Conversation
Centralizes offset tracking, append-vs-replace, and end-of-list detection for offset-based list endpoints on top of useOptimisticData and the shared pagination helpers, so pages stop reimplementing the same logic (and its off-by-one/end-of-list bugs) individually. Migrates BrowsePage as the reference consumer. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Closes #304.
usePaginatedQuery<T>(src/shared/hooks/usePaginatedQuery.ts), a typed pagination layer built onuseOptimisticDataand the shared helpers insrc/shared/utils/pagination.ts. It centralizes offset tracking, append-vs-replace, and end-of-list detection (viahasMoreByTotal/hasMoreByPageSize) so list pages stop reimplementing this logic — and its off-by-one/end-of-list bugs — individually.BrowsePageas the reference consumer: its bespokeloadProjects/offset-ref/loadingMore-ref/request-seq-ref block is replaced by a singlefetchProjectsPagecallback + oneusePaginatedQuerycall. Net diff onBrowsePage.tsxis -76 lines.limit/offsetare clamped to bounded, non-negative integers (viaclampLimit/clampOffset) before ever reachingfetchPage, so a caller-influenced page size or a runaway offset can't reach the API unbounded.useOptimisticData. Every page request bypassesuseOptimisticData's response cache (forceRefresh), since each call targets a different offset — a single-key cache would otherwise risk replaying a stale page instead of fetching the next one.PaginatedPage<T>contract has an explicitreceivedfield, distinct fromitems.length, so afetchPagethat filters/maps rows (likeBrowsePagedropping invalid repos) still advances the offset by the API's raw page size — not the smaller displayed count. This preserves a subtlety the originalBrowsePagecode handled carefully and that would otherwise reintroduce exactly the kind of off-by-one bug this issue is about preventing.API
The first page loads automatically on mount and again whenever
fetchPage's identity changes (e.g. auseCallbackkeyed on filters) — mirroring howBrowsePagepreviously reloaded onselectedFilterschanges, with no extra effect needed in the consumer.Test plan
New
src/shared/hooks/usePaginatedQuery.test.ts(17 tests), covering:hasMoretofalseimmediatelytotalknown, fits in one page) does not show load-morehasMoreflipstrue→falseon the page that exactly exhauststotaltotalvia the page-size heuristicloadMoreappends rather than replaces; is a no-op oncehasMoreisfalse; guards against concurrent double-clicksreset()discards accumulated items and refetches from offset 0fetchPage's identity changes (filter change)receivedcount, not the (possibly smaller) filtereditemscountitemshandled defensivelyhasError/errorwithout clearing previously-loaded items orhasMoreretry()re-attempts the exact failed page (same offset) and recoversresetalready landed is ignoredRan via
npx vitest run usePaginatedQuery BrowsePage: 41/41 passing (17 new + 24 existingBrowsePagetests, unmodified, still green).Coverage for the new hook (
npx vitest run usePaginatedQuery --coverage): 100% lines/statements/functions/branches.BrowsePage.tsx's existing coverage is unchanged-to-slightly-improved post-migration (branches 89.16% → 91.3%, statements 97.5% → 99.11%).npx tsc --noEmitandnpx eslint srcshow no new errors introduced by this change (both have a handful of pre-existing, unrelated warnings/errors onmain— e.g.RefObjecttyping inSearchModal.tsx/Modal.tsx/focusTrap.test.tsx— left untouched as out of scope).Security notes
limitis clamped to[1, MAX_PAGE_LIMIT]andoffsetto[0, MAX_OFFSET]via the existingpagination.tshelpers inside the hook itself, before any value reachesfetchPage/the API — a consumer cannot accidentally (or via malicious filter input) request an oversized page or an unbounded deep-paging offset.