fix(admin): keep content list view state in the URL - #2246
Conversation
The content list held its page in ContentList's useState and its sort, search and filters in ContentListPage's. Opening an entry unmounts the list, so the browser's back button dropped all of it and returned the editor to an unfiltered page 1. Move the whole view onto the route's search params. Defaults are never serialized, so a pristine list stays at /content/posts with no query string and clearing a control drops its param again. Every filter and sort change resets the page, since page 4 of one filter set indexes nothing in another. Discrete controls push a history entry; the debounced search and the date inputs replace, to keep history usable. Params are validated rather than trusted: an out-of-vocabulary sort column, a fractional page, or a free-form date bound is dropped instead of being forwarded to the API. The All/Trash tab is intentionally not included. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: c6cca1a The changes in this PR will be included in the next version bump. This PR includes changesets to release 17 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/auth-atproto
@emdash-cms/blocks
@emdash-cms/cloudflare
@emdash-cms/contentful-to-portable-text
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/plugin-cli
@emdash-cms/plugin-types
@emdash-cms/registry-client
@emdash-cms/registry-lexicons
@emdash-cms/registry-verification
@emdash-cms/sandbox-workerd
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-field-kit
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
There was a problem hiding this comment.
This is the right fix for the reported bug: moving the content-list view state into TanStack Router search params so opening an entry and using the browser back button restores the page, search, filters, and sort. The approach keeps the component backward-compatible by adding opt-in page/onPageChange and initialSearchQuery props to ContentList, and it validates query params before forwarding them to the API. I read the diff, the full changed files, and traced ContentList and its tests; the logic is sound, the pagination/sort/search wiring is consistent, regression tests are included (plus parser unit tests), and there is a package changeset.
No logic bugs, regressions, security issues, or AGENTS convention problems beyond comment discipline. The new router comments include PR-description-style paragraphs that belong in the PR body, not in the source file.
Review feedback: the route-level and page-reset comment blocks restated the PR description rather than recording an invariant. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thanks — both comment notes addressed in 0d30ab6. The route-level block is now the suggested one-liner, and the page-reset paragraph is gone (the sort comment kept). I also trimmed the On the red Integration Tests check: it was unrelated to this PR, which touches only |
There was a problem hiding this comment.
This is the right fix for the reported bug: moving the content-list view state into TanStack Router search params so opening an entry and pressing back restores page, search, filters, and sort. The approach fits EmDash well — the new page/onPageChange and initialSearchQuery props are additive and keep ContentList backward-compatible, query keys are unchanged, query params are validated before being used, regression tests and parser unit tests are included, and there is a package changeset.
This is a re-review of my earlier COMMENTED review. No author reply was provided, and the comment-discipline issues I raised are still present in the current head. Beyond those source comments, I found no logic bugs, regressions, security issues, i18n/RTL leaks, broken API envelope handling, missing locale filtering, or new logged-out query work.
Two clear AGENTS.md comment violations remain and should be cleaned up before merge:
packages/admin/src/router.tsx:313has a PR-description sentence that just restates what the route/search schema already says.packages/admin/src/router.tsx:459references issue#1288in a prose comment.
Once those are trimmed, the code is good to land.
Removes the route-level summary of the search-param behavior and the issue reference on the filter-state comment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
This is the right fix for the reported bug: moving the content-list view state (page, search, filters, sort, date range) into TanStack Router search params so opening an entry and pressing back restores the view the editor left. The approach fits EmDash well — the new page/onPageChange and initialSearchQuery props are additive and keep ContentList backward-compatible, query params are validated before use, defaults are omitted from the URL, and the regression tests cover the back-navigation round trip.
Since the prior COMMENTED review, one of the two comment-discipline issues has been cleaned up: the #1288 issue reference in router.tsx is gone. The other issue — the prose comment above ContentListSearch that restates the interface — is still present. I found no other logic bugs, regressions, security issues, i18n/RTL leaks, broken API envelope handling, missing locale filtering, or new logged-out query work.
Once the remaining restatement comment is removed, this is good to land.
Keeps the cross-route constraint on optionality, drops the summary of what the interface already declares. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
This remains the right fix for the reported bug: moving content-list view state into TanStack Router search params so back-navigation restores the page/filters/sort/search the editor left. The additive page/onPageChange/initialSearchQuery props keep ContentList backward-compatible, defaults are omitted from the URL, params are validated before use, and the regression tests cover the back-navigation round trip.
Since the prior COMMENTED review, the #1288 issue-reference comment in router.tsx has been removed as noted. I rechecked the change for logic bugs, security (validated params, no new SQL), i18n/RTL surface (no new strings or layout), missing locale filtering, and extra logged-out query work; nothing new to report.
One AGENTS.md comment-discipline issue from the prior review is still present: the prose comment above ContentListSearch restates/justifies the interface design, which the file explicitly shows. Once that comment is removed, this is good to land.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3cd5f8a to
253c25f
Compare
There was a problem hiding this comment.
This is the right fix for the reported bug: moving the content-list view state (page, search, filters, sort, date range) into TanStack Router search params means the browser back button restores the view an editor left. The additive, opt-in page/onPageChange/initialSearchQuery props keep ContentList backward-compatible, defaults are omitted from the URL, params are validated before use, page resets on filter/sort changes, and the regression tests cover the round-trip behavior. It fits EmDash's existing patterns and doesn't touch SQL, auth, or logged-out routes.
The prior review's one open item — a prose comment above ContentListSearch justifying the interface — has been removed, so that finding is resolved.
Remaining issues are mostly AGENTS.md comment-discipline cleanup:
-
Several newly-added comments in
packages/admin/src/router.tsxandpackages/admin/tests/router.test.tsxjustify design decisions, restate what the code already shows, or narrate the original bug/reviewer pitfall. AGENTS.md is explicit that comments should be for future readers, not PR summaries or reviewer messages. The line-anchored findings call out representative examples (lines 400-403, 416-417, 438-439, 718-719 inrouter.tsxand 809-811, 822-823, 840-841 inrouter.test.tsx). There are similar instances nearby (e.g., thedir/sortrationale at line 354-355) that should be trimmed in the same pass. -
A smaller correctness concern:
ContentListseedssearchQueryfrominitialSearchQueryonly on mount. If the route'sqsearch param changes while the list is already mounted — for example, an in-app deep link from another page to/content/posts?q=foo— the input box stays at the old value and the debounced effect can push that stale value back into the URL. The current tests don't exercise externalqchanges while mounted. Consider making the search input react to URL-driven changes without breaking the debounced typing UX.
Recommended verdict is comment: the fix is sound and the prior open item is addressed, but the new comments need the cleanup pass described in AGENTS.md before landing.
ContentList seeded its search box from the caller once, on mount. A `q` change while the list stayed mounted — a deep link, or back-navigation across a search — left the input showing the old term over unfiltered results. The input keeps its own state so typing isn't lagged by the debounce, and now adopts the caller's term whenever it differs from the one the list last reported, so the caller echoing our own query back can't reset the input mid-word. `initialSearchQuery` becomes `searchQuery` to match. Also drops the comments the review flagged in the route and its tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Addressed in 3ee2029. Comment cleanup — removed all seven flagged comments, plus the Two comments in the test file I kept, since they document a footgun rather than the change:
The Failing test first ( I didn't move the debounce up to
|
There was a problem hiding this comment.
This is still the right fix: moving the content-list view state into TanStack Router search params makes back-navigation restore the editor’s view, keeps the component backward-compatible through additive controlled props, validates params before forwarding them, and resets page on filter/sort changes. I re-read the changed files and the diff; the prior open item about the search input not reacting to external q changes while mounted is now addressed by a sync effect in ContentList and a regression test.
The only remaining issue from the prior review is the AGENTS.md comment-discipline cleanup that was requested before landing. A few comments introduced or left by this PR still justify design decisions, narrate the original test pitfall, or duplicate PR-description notes. I’ve line-anchored the representative instances; removing them is a straightforward cleanup pass with no behavior change. No logic bugs, regressions, security issues, or missing tests were found. The changeset, exports, prop typings, and test coverage look good.
|
Ok, at this point the bot is just nitpicking |
khoinguyenpham04
left a comment
There was a problem hiding this comment.
Thank you for tackling this. Could you please address these comments.
| const rawPage = Number(search.page); | ||
| const page = Number.isInteger(rawPage) && rawPage > 1 ? rawPage : undefined; |
There was a problem hiding this comment.
Could we avoid replaying the full cursor chain for deep page URLs? On a fresh load, ?page=500 fetches every preceding API page and retains all rows. Could we bound the automatic loading or use cursor-backed URL state?
There was a problem hiding this comment.
Agreed that this is worth solving, but I don't think it should be solved in this PR — and I don't think bounding the loading is the right shape either. Opened #2255 for it.
Short version of what I found while looking into it. The replay is a symptom of the URL storing an ordinal when we already have an anchor: encodeCursor produces { orderValue, id } and findMany applies it as a comparison, never a row lookup, so it stays valid even if the anchored row is deleted. ?after=<cursor> reads limit rows at page 2 and page 500 alike, restores in one request on back-navigation, and holds its window steady while other editors save — which the current design and offset pagination both fail to do.
It also collapses the two-tier pagination that causes the replay: the API pages at 100 while the table slices at 20, which is the only reason the auto-chaining effect exists. Match those and one URL page is one API page.
Two things it needs that aren't free — a before cursor in findMany for the ← button on a cold-loaded deep link, and a decision about the page-number label, since an anchor doesn't know it's the twelfth page. Both are in the Discussion.
Cursor-backed URL state is your other suggestion, so I think we agree on the direction; I'd rather get a maintainer read on the label trade-off before rewriting the list's pagination model.
On this PR specifically: the cost is bounded even today. totalPages comes from the server's total and clampedPage pins the page to it, so ?page=999999 settles onto the last real page after one fetch. What's left is a genuinely large collection reached by a deep bookmark, which is unchanged from what clicking there already costs.
Two review findings on the content list URL state. `readCalendarDate` matched `YYYY-MM-DD` by shape only, so `?dateFrom=2025-99-99` was forwarded to the API and `?dateTo=2025-02-31` reached it widened to a 2025-03-03 boundary. It now round-trips the value through `Date`, which is what catches days the calendar doesn't have. `ContentList` also let a search term still inside its 300ms debounce land after the user pressed Back: typing sends the list to page 1, back restores page 2, and the debounce then replaced the restored page with `?q=...`. A page the caller moved on its own now re-seeds the input, dropping the pending timer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Moved this to draft pending a maintainer call on #2255. Both of the local findings from the review are fixed in c6cca1a, each with a failing test first: What's holding it is the third comment. Chasing it turned up something bigger than the fix it asked for: the deep-page replay is a symptom of this PR storing an ordinal in the URL when the API already hands out a stable anchor. If #2255 lands the anchor direction, The alternatives I weighed and rejected: capping the restorable page in the parser clamps whether or not the rows are already cached, so a legitimate back-navigation from a deep page lands on the wrong one — the same class of bug this PR exists to fix. Bounding the auto-chaining by loaded data avoids that, but it's a real change to the list's pagination model, which is the thing #2255 is asking about. So the choice is which URL shape this route gets, and that's a maintainer's call rather than something to settle inside a bug-fix PR. Happy to mark it ready again unchanged if the answer is "ship the page number, revisit later" — the reported back-button bug is fixed either way, and the deep-page cost is bounded today by the |
What does this PR do?
Reported by an editor and reproduced: on a collection's entry list, go to page 2 or 3, open an entry, then press the browser's back button — you land back on page 1.
pagelived inContentList'suseState, and sort/search/status/author/date lived inContentListPage's. Opening an entry unmounts the list, so back threw all of it away.This moves the whole list view onto the route's search params, so back-navigation restores the view the editor left. List URLs become shareable and bookmarkable as a side effect.
/content/postswith no query string; clearing a control drops its param again. Page is 1-based in the URL and omitted on page 1.?sort=authorId,?page=2.5,?dateFrom=yesterdayare dropped rather than forwarded to the API.ContentListgains optionalpage/onPageChangeandinitialSearchQueryprops, following the existing opt-in pattern used bysort/onSortChangeand the filter callbacks. The component is exported from@emdash-cms/admin, so all three are additive — callers that don't pass them keep the current internal-state behavior and a working pager.Not included: the All/Trash tab
activeTabis stilluseStateinContentList, so back always lands on All. It was out of scope for the reported bug and the trash tab has no pagination of its own.The infrastructure does generalize to it — adding it later is the same three moves this PR already makes for
page: atabentry inparseContentListSearch, anactiveTab/onTabChangecontrolled prop pair onContentListmirroringpage/onPageChange, and a page reset on tab switch. Happy to fold it in here if a reviewer would rather have it in one go.Type of change
Checklist
pnpm typecheckpassespnpm lintpasses —pnpm lint:jsonreports 0 diagnosticspnpm testpasses (or targeted tests for my change) — fullpackages/adminsuite, 1251 passedpnpm formathas been runmessages.pochanges includedAI-generated code disclosure
Screenshots / test output
Failing tests written first, then the fix.
Four browser-level regression tests in
tests/router.test.tsx— page survives back-navigation, filters and sort survive back-navigation, pristine URLs stay free of query params, and page + search term restore together. Nine parser unit tests intests/router-search.test.ts, alongside the existingparseBylinesLocaleSearchones.Two notes for reviewers:
ContentEditorin that file renders the entry title, sogetByText("Post 21")matched the editor rather than the list. The assertions now target the page indicator and the row links. Worth knowing when adding tests to that file.handleSearchChange:ContentListreports its seeded search query once when the debounce settles on mount, and treating that as a user edit wipes the page just restored from the URL. I removed the guard, confirmedrestores a page and a search term togetherfails, and put it back.Not verified by hand against a seeded site — the suite runs in Chromium and covers the round trip, but I did not drive the real admin through
dev-bypass. No new UI strings or layout, so there is no RTL/Arabic surface to re-check.🤖 Generated with Claude Code