You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
PR #2246 moves the admin content list's view state into the URL so back-navigation restores the page an editor left. In review, @khoinguyenpham04 pointed out what that exposes: on a cold load, /content/posts?page=500 replays the entire cursor chain — roughly 100 sequential API requests, with every row retained — before the table paints.
The bug that PR fixes is real and the fix is small, but the replay is a symptom of something more structural: the URL stores an ordinal where we already have an anchor.
Why the replay happens
Two page sizes are in play. The admin list requests limit: 100 from GET /_emdash/api/content/{collection}, and ContentList slices the loaded rows at PAGE_SIZE = 20. To display visible page N it needs (N+1) * 20 rows, so an effect keeps calling fetchNextPage until it has them. Cursor pagination can't skip: page 20's cursor only exists once page 19 has come back, so those fetches are strictly sequential.
The cost is bounded — totalPages comes from the server's total, and clampedPage pins a bogus ?page=999999 to the last real page after one fetch — so a crafted URL can't invent work that isn't there. But a genuinely large collection plus a bookmarked deep page pays the full chain.
Why not offset
The obvious answer is offset pagination, and half of it already exists: query.ts defines CollectionFilter as a union of CursorCollectionFilter and OffsetCollectionFilter, loader.ts builds the clause with the SQLite/Postgres difference handled, and api/schemas/common.ts declares an offsetPaginationQuery that nothing currently imports. The offset variant is documented for numbered archive routes — /blog/page/2.
Adding offset to contentListQuery and ContentRepository.findMany would work, but it buys the weaker guarantee:
Cost still grows with depth.OFFSET 9980 reads 10,000 index rows and discards 9,980. One round trip instead of a hundred, but D1 bills rows read, and an endpoint whose cost scales with how deep the caller has gone is a poor default.
Windows drift under writes. The list sorts by updated_at DESC by default, so every save reorders rows. A row can slide from page 2 to page 1 between requests and never be seen, or be seen twice. This is exactly what keyset pagination exists to prevent.
Proposal: put the anchor in the URL
encodeCursor produces { orderValue, id }, and findMany applies it as a comparison — WHERE updated_at < ? OR (updated_at = ? AND id < ?). It never looks the anchor row up, so deleting the row you bookmarked doesn't invalidate the bookmark; the window resumes where that value would have been. That is already a stable position marker. We just aren't storing it.
/content/posts?after=<cursor> reads limit rows at page 2 and at page 500 alike, restores in a single request on back-navigation, and holds its window steady while other editors save.
It also collapses the two-tier pagination that caused this. Set the list's API limit to the display page size and one URL page equals one API page: the auto-chaining effect, the loaded-vs-total clamp, and the client-side slicing in ContentList all go away. total already comes back on every page, so the denominator survives.
What it needs
A before cursor in findMany. The comparison direction is derived from the sort direction, so cursors are forward-only today and the pager's ← button has nothing to navigate with on a cold-loaded deep link. Reversing the comparison, reversing the order, fetching limit + 1 and reversing the rows back is symmetric with the branch already there — and unlike offset it adds no rows-read growth and no dialect quirk.
A decision on the page label. An anchor doesn't know it's the twelfth page. Either carry page alongside as a display-only counter (correct when you walked there, possibly off by a row on a link shared after edits), or change the indicator to "showing 221–240 of 1,043". Exact ordinals are the one thing offset does better — but the pager is prev/next only, with no jump-to-page control, so nothing in the UI depends on the ordinal being addressable.
Graceful handling of a stale cursor. A hand-edited cursor reaches decodeCursor, which throws InvalidCursorError and surfaces as a structured INVALID_CURSOR. That's deliberate — the doc comment says it exists so pagination bugs don't silently re-fetch page 1 — but once the cursor sits in a user-editable URL, the list should catch that code and reset to the first page rather than render an error.
Scope
This replaces the admin list's pagination model, so it isn't something to fold into a bug-fix PR. #2246 stands on its own: it fixes the reported back-button bug, and the two other review findings on it are fixed. The deep-page cost is bounded there, not eliminated.
Looking for a maintainer read on two things before any implementation PR:
Is anchor-in-URL the direction, or would you rather have offset for the exact page numbers?
If anchor: display-only page counter, or switch the indicator to a row range?
reacted with thumbs up emoji reacted with thumbs down emoji reacted with laugh emoji reacted with hooray emoji reacted with confused emoji reacted with heart emoji reacted with rocket emoji reacted with eyes emoji
Uh oh!
There was an error while loading. Please reload this page.
The problem
PR #2246 moves the admin content list's view state into the URL so back-navigation restores the page an editor left. In review, @khoinguyenpham04 pointed out what that exposes: on a cold load,
/content/posts?page=500replays the entire cursor chain — roughly 100 sequential API requests, with every row retained — before the table paints.The bug that PR fixes is real and the fix is small, but the replay is a symptom of something more structural: the URL stores an ordinal where we already have an anchor.
Why the replay happens
Two page sizes are in play. The admin list requests
limit: 100fromGET /_emdash/api/content/{collection}, andContentListslices the loaded rows atPAGE_SIZE = 20. To display visible page N it needs(N+1) * 20rows, so an effect keeps callingfetchNextPageuntil it has them. Cursor pagination can't skip: page 20's cursor only exists once page 19 has come back, so those fetches are strictly sequential.The cost is bounded —
totalPagescomes from the server'stotal, andclampedPagepins a bogus?page=999999to the last real page after one fetch — so a crafted URL can't invent work that isn't there. But a genuinely large collection plus a bookmarked deep page pays the full chain.Why not offset
The obvious answer is offset pagination, and half of it already exists:
query.tsdefinesCollectionFilteras a union ofCursorCollectionFilterandOffsetCollectionFilter,loader.tsbuilds the clause with the SQLite/Postgres difference handled, andapi/schemas/common.tsdeclares anoffsetPaginationQuerythat nothing currently imports. The offset variant is documented for numbered archive routes —/blog/page/2.Adding
offsettocontentListQueryandContentRepository.findManywould work, but it buys the weaker guarantee:OFFSET 9980reads 10,000 index rows and discards 9,980. One round trip instead of a hundred, but D1 bills rows read, and an endpoint whose cost scales with how deep the caller has gone is a poor default.updated_at DESCby default, so every save reorders rows. A row can slide from page 2 to page 1 between requests and never be seen, or be seen twice. This is exactly what keyset pagination exists to prevent.Proposal: put the anchor in the URL
encodeCursorproduces{ orderValue, id }, andfindManyapplies it as a comparison —WHERE updated_at < ? OR (updated_at = ? AND id < ?). It never looks the anchor row up, so deleting the row you bookmarked doesn't invalidate the bookmark; the window resumes where that value would have been. That is already a stable position marker. We just aren't storing it./content/posts?after=<cursor>readslimitrows at page 2 and at page 500 alike, restores in a single request on back-navigation, and holds its window steady while other editors save.It also collapses the two-tier pagination that caused this. Set the list's API
limitto the display page size and one URL page equals one API page: the auto-chaining effect, the loaded-vs-totalclamp, and the client-side slicing inContentListall go away.totalalready comes back on every page, so the denominator survives.What it needs
A
beforecursor infindMany. The comparison direction is derived from the sort direction, so cursors are forward-only today and the pager's ← button has nothing to navigate with on a cold-loaded deep link. Reversing the comparison, reversing the order, fetchinglimit + 1and reversing the rows back is symmetric with the branch already there — and unlike offset it adds no rows-read growth and no dialect quirk.A decision on the page label. An anchor doesn't know it's the twelfth page. Either carry
pagealongside as a display-only counter (correct when you walked there, possibly off by a row on a link shared after edits), or change the indicator to "showing 221–240 of 1,043". Exact ordinals are the one thing offset does better — but the pager is prev/next only, with no jump-to-page control, so nothing in the UI depends on the ordinal being addressable.Graceful handling of a stale cursor. A hand-edited cursor reaches
decodeCursor, which throwsInvalidCursorErrorand surfaces as a structuredINVALID_CURSOR. That's deliberate — the doc comment says it exists so pagination bugs don't silently re-fetch page 1 — but once the cursor sits in a user-editable URL, the list should catch that code and reset to the first page rather than render an error.Scope
This replaces the admin list's pagination model, so it isn't something to fold into a bug-fix PR. #2246 stands on its own: it fixes the reported back-button bug, and the two other review findings on it are fixed. The deep-page cost is bounded there, not eliminated.
Looking for a maintainer read on two things before any implementation PR:
pagecounter, or switch the indicator to a row range?All reactions