Follow-up to #4296, split out so the first version of limel-pagination stays reviewable. The design below came out of a review session with Claude Opus 5; the decision to defer it is mine.
New feature motivation
limel-pagination as specified in #4296 derives everything from totalItems and pageSize. That covers any source that can count, which is most of them — but not all.
Some sources structurally cannot give you a total:
- Cursor / keyset pagination. The API returns a page and a cursor, and answering "how many in total" would mean a second, expensive query the endpoint deliberately does not do.
- Live streams. An activity feed is reverse-chronological and still growing; a total is stale the moment it is computed.
- Deliberately uncounted queries. APIs that let a caller opt out of the
COUNT for performance return rows and no count at all — a real and common optimisation on large datasets.
For all of these a pagination component can still be useful — you can still move forward and back, and still jump to a page you have already seen — but it cannot render "page 7 of 492", because nobody knows what 492 is.
Without this, the component simply does not serve that class of source, and consumers fall back to a "Load more" button with all the problems described in #4296.
New feature description
Entered by passing no totalItems. Not by a flag, which could contradict it.
- The range readout drops "of N" rather than guessing at it:
4 921–4 940, not 4 921–4 940 of ?.
- Pages are revealed as they are visited. The number window shows
1 … current, with a trailing ··· standing in for the unseen remainder.
next is driven by how full the current page is, since there is no arithmetic to do. See below.
- Jumping forward past the revealed range is not offered — you cannot jump to a page nobody knows exists. The ellipsis popover is still available for the revealed range behind you.
How next knows when to stop
The obvious API is a hasNextPage boolean, and it is the wrong one: it asks the consumer for a conclusion. Every consumer would then compute that conclusion the same way, by hand, and some of them would get it wrong.
Instead the consumer reports a fact:
@Prop() itemsOnCurrentPage: number; // items.length from the response
From which the component derives both next's availability — a full page implies there is more, a short page means this is the end — and an honest range readout on the final page. That second part matters: without it the component computes to = page * pageSize and the last page claims 4 921–4 940 when only twelve rows came back.
Accepted imprecision. When the true last page happens to be exactly full, next stays enabled for one more click, which lands on an empty page and then disables itself. That is the classic behaviour of every length-inferring "load more", and it buys an API that a consumer cannot get wrong. A hasNextPage override for sources that genuinely know the answer can be added later if a real case turns up; it should not be the primary mechanism.
New feature implementation
The main design problem is that totalItems: null will then mean two different things. In #4296 it means the count has not arrived yet — a consumer that fetches rows and the count as separate requests, and renders as soon as the rows land. Here it would mean this source never counts. The two want opposite behaviour: the first should hold its geometry and wait, the second should switch into reveal-as-you-go permanently.
The component cannot tell them apart from null alone, and getting it wrong is visible — a control that flips into reveal-as-you-go and back again on every filter change.
Options, none obviously best:
- A discriminator prop (
totalItemsLoading, or similar) meaning "a count is coming, hold everything". Explicit, but it is a second prop describing the same fact from the other side.
totalItems?: number | null with undefined meaning pending and null meaning never-counts. No extra prop, but a distinction most consumers will not notice and TypeScript will not protect.
- An explicit mode, e.g.
countability: 'counted' | 'uncounted'. Verbose, but unambiguous and self-documenting.
This should be settled before implementing, not during. My weak preference is 3 — it is the only one that reads correctly at the call site — but it adds a prop to the common case to serve the uncommon one, which is the usual argument against.
Also worth deciding at the same time: whether the revealed-page list should survive a remount. It is held in component state today, so a consumer that unmounts the pagination component during navigation loses it. An earlier draft had a knownPages prop for this; it was cut because consumers that care keep position in the URL and restore page from it, and the revealed set is always 1..page. Worth re-checking against a real consumer before assuming it stays cut.
What the shipped component tells us about the three options
limel-pagination landed in #4296 with the transient half of this already live, so the ambiguity is no longer hypothetical and there is evidence for which option to take.
The distinction is already load-bearing in ten places. Every decision the component makes about a missing count is a separate === null test, spread across three different meanings — "never had one", "one is in flight", "the value was unusable". In pagination.tsx: readOut (194), rangeIdentity (213), reconcile (241), slotsFor (301), isArrowDisabled (439), rememberTotalItems (495), the pageCount getter (507), knownTotalItems (522), capToLastPage (604) and rangeLabel (651). Two of those were added by fixes that came out of reviewing the first version, so the count grows rather than settles. Whichever option wins has to be read in all of them, which is the argument for making the state nameable rather than inferring it from a null.
The strongest evidence is that the current behaviour is already a small lie to the user. With no count and a page past the first, the control renders exactly one page button carrying aria-current="page", a disabled next and an enabled previous. That is byte-for-byte what a genuine single-page set looks like. Read out, it asserts "page 9, current page, in a set of one page you can only go back from". Nothing in it conveys that the size is merely unknown.
That matters for the choice here because it is not an internal ambiguity the component can resolve quietly — the two states need to look and sound different, so the component has to be able to name which one it is in. Option 3 is the only one of the three that makes that nameable at the call site; options 1 and 2 leave the component inferring it. The objection to option 3 still stands — it adds a prop to the common case to serve the uncommon one — but the uncommon case is now known to be user-visible rather than merely awkward, which is a heavier counterweight than it looked before.
Worth settling at the same time: what the uncounted state should actually render before anything is known. "One page, and you are on page 9 of it" is wrong in both modes.
Follow-up to #4296, split out so the first version of
limel-paginationstays reviewable. The design below came out of a review session with Claude Opus 5; the decision to defer it is mine.New feature motivation
limel-paginationas specified in #4296 derives everything fromtotalItemsandpageSize. That covers any source that can count, which is most of them — but not all.Some sources structurally cannot give you a total:
COUNTfor performance return rows and no count at all — a real and common optimisation on large datasets.For all of these a pagination component can still be useful — you can still move forward and back, and still jump to a page you have already seen — but it cannot render "page 7 of 492", because nobody knows what 492 is.
Without this, the component simply does not serve that class of source, and consumers fall back to a "Load more" button with all the problems described in #4296.
New feature description
Entered by passing no
totalItems. Not by a flag, which could contradict it.4 921–4 940, not4 921–4 940 of ?.1 … current, with a trailing···standing in for the unseen remainder.nextis driven by how full the current page is, since there is no arithmetic to do. See below.How
nextknows when to stopThe obvious API is a
hasNextPageboolean, and it is the wrong one: it asks the consumer for a conclusion. Every consumer would then compute that conclusion the same way, by hand, and some of them would get it wrong.Instead the consumer reports a fact:
From which the component derives both
next's availability — a full page implies there is more, a short page means this is the end — and an honest range readout on the final page. That second part matters: without it the component computesto = page * pageSizeand the last page claims4 921–4 940when only twelve rows came back.Accepted imprecision. When the true last page happens to be exactly full,
nextstays enabled for one more click, which lands on an empty page and then disables itself. That is the classic behaviour of every length-inferring "load more", and it buys an API that a consumer cannot get wrong. AhasNextPageoverride for sources that genuinely know the answer can be added later if a real case turns up; it should not be the primary mechanism.New feature implementation
The main design problem is that
totalItems: nullwill then mean two different things. In #4296 it means the count has not arrived yet — a consumer that fetches rows and the count as separate requests, and renders as soon as the rows land. Here it would mean this source never counts. The two want opposite behaviour: the first should hold its geometry and wait, the second should switch into reveal-as-you-go permanently.The component cannot tell them apart from
nullalone, and getting it wrong is visible — a control that flips into reveal-as-you-go and back again on every filter change.Options, none obviously best:
totalItemsLoading, or similar) meaning "a count is coming, hold everything". Explicit, but it is a second prop describing the same fact from the other side.totalItems?: number | nullwithundefinedmeaning pending andnullmeaning never-counts. No extra prop, but a distinction most consumers will not notice and TypeScript will not protect.countability: 'counted' | 'uncounted'. Verbose, but unambiguous and self-documenting.This should be settled before implementing, not during. My weak preference is 3 — it is the only one that reads correctly at the call site — but it adds a prop to the common case to serve the uncommon one, which is the usual argument against.
Also worth deciding at the same time: whether the revealed-page list should survive a remount. It is held in component state today, so a consumer that unmounts the pagination component during navigation loses it. An earlier draft had a
knownPagesprop for this; it was cut because consumers that care keep position in the URL and restorepagefrom it, and the revealed set is always1..page. Worth re-checking against a real consumer before assuming it stays cut.What the shipped component tells us about the three options
limel-paginationlanded in #4296 with the transient half of this already live, so the ambiguity is no longer hypothetical and there is evidence for which option to take.The distinction is already load-bearing in ten places. Every decision the component makes about a missing count is a separate
=== nulltest, spread across three different meanings — "never had one", "one is in flight", "the value was unusable". Inpagination.tsx:readOut(194),rangeIdentity(213),reconcile(241),slotsFor(301),isArrowDisabled(439),rememberTotalItems(495), thepageCountgetter (507),knownTotalItems(522),capToLastPage(604) andrangeLabel(651). Two of those were added by fixes that came out of reviewing the first version, so the count grows rather than settles. Whichever option wins has to be read in all of them, which is the argument for making the state nameable rather than inferring it from anull.The strongest evidence is that the current behaviour is already a small lie to the user. With no count and a page past the first, the control renders exactly one page button carrying
aria-current="page", a disablednextand an enabledprevious. That is byte-for-byte what a genuine single-page set looks like. Read out, it asserts "page 9, current page, in a set of one page you can only go back from". Nothing in it conveys that the size is merely unknown.That matters for the choice here because it is not an internal ambiguity the component can resolve quietly — the two states need to look and sound different, so the component has to be able to name which one it is in. Option 3 is the only one of the three that makes that nameable at the call site; options 1 and 2 leave the component inferring it. The objection to option 3 still stands — it adds a prop to the common case to serve the uncommon one — but the uncommon case is now known to be user-visible rather than merely awkward, which is a heavier counterweight than it looked before.
Worth settling at the same time: what the uncounted state should actually render before anything is known. "One page, and you are on page 9 of it" is wrong in both modes.