diff --git a/.changeset/clean-cursors-paginate.md b/.changeset/clean-cursors-paginate.md
new file mode 100644
index 0000000000..85b3448f32
--- /dev/null
+++ b/.changeset/clean-cursors-paginate.md
@@ -0,0 +1,5 @@
+---
+"@shopify/hydrogen": patch
+---
+
+Reset `before` and `after` pagination cursors when collection filters or sorting change.
diff --git a/packages/hydrogen/skills/hydrogen-cart-drawer/SKILL.md b/packages/hydrogen/skills/hydrogen-cart-drawer/SKILL.md
index 0f1dfd2d41..b019bbbf6b 100644
--- a/packages/hydrogen/skills/hydrogen-cart-drawer/SKILL.md
+++ b/packages/hydrogen/skills/hydrogen-cart-drawer/SKILL.md
@@ -81,7 +81,7 @@ Use a primitive library only when the app already depends on one for dialog-like
### Opening the drawer
-The cart trigger renders in SSR as `Cart` so it works before hydration and without JavaScript (the anchor navigates to the full `/cart` page). After hydration, an `onClick` calls `e.preventDefault()` then `openCartDrawer()` (which calls `showModal()`), so the click opens the drawer instead of navigating; no `hasHydrated` swap is needed — the anchor is the no-JS baseline and the `onClick` is the enhancement.
+Always render the cart trigger as a real `/cart` link so SSR and no-JS navigation reach the full cart page. After hydration, enhance normal activation to open the drawer instead of navigating. Preserve native link behavior for modified activation and new tabs. This is the same progressive-enhancement pattern used by client-side routers.
Keep the drawer as hydrated progressive enhancement. Do not make the drawer itself the fallback route; the `/cart` page is that full-page fallback.
@@ -165,14 +165,12 @@ function CartDrawer() {
The drawer opens from three surfaces. The cart trigger is the canonical one; the other two reuse the same helper.
-**1. The cart trigger.** A `/cart` anchor with an `onClick` that opens the drawer after hydration. It carries the accessibility attributes `aria-controls` and `aria-haspopup="dialog"`:
+**1. The cart trigger.** Keep link semantics before and after hydration. Once hydrated, intercept only unmodified, same-context activation: prevent navigation and call `openCartDrawer()`. Modified activation (new tab, download, etc.) keeps native link behavior. When enhancement is active, add `aria-haspopup="dialog"`, `aria-controls`, and `aria-expanded`; keep `aria-expanded` synchronized with the drawer's open state.
-```tsx
-return (
- { e.preventDefault(); openCartDrawer(); }} aria-controls={CART_DRAWER_ID} aria-haspopup="dialog">
- Cart
-
-);
+```text
+without enhancement: Cart -> navigate to /cart
+with enhancement: Cart -> open the drawer for normal activation
+guard: modified activation (new tab, etc.) keeps native navigation
```
**2. `window.Shopify.actions.openCart()`** — the Standard Action for programmatic opening, so external code (Standard Actions tools, agents, third-party components) can open the drawer. Register the same stable DOM helper as the `openCart` handler (see §8 for the handler-permanence caveat).
@@ -181,7 +179,7 @@ return (
window.Shopify.actions.openCart();
```
-**3. From add-to-cart** — when the canonical cart trigger is an anchor, open the drawer immediately with optimistic state so pending cart contents remain inspectable. Opening only after success is compatible with the storefront contract only when the page also provides a visible button that opens the drawer while the mutation is pending.
+**3. From add-to-cart** — open the drawer immediately with optimistic state so pending cart contents remain inspectable. Opening only after success is compatible with the storefront contract only when the page also provides a visible control that opens the drawer while the mutation is pending.
```tsx
);
}
+function FilterSidebar(props: FilterSidebarProps) {
+ return (
+
+ );
+}
+
function requestFormSubmit(event: React.ChangeEvent) {
event.currentTarget.form?.requestSubmit();
}
```
-Pass `method="get"` and an explicit `action={collectionPath}` (the current `/collections/:handle` or `/search` route URL) literally — `formProps()` only wires the submit handler (see the SKILL.md UI rule).
+Pass `method="get"` and an explicit `action={collectionPath}` (the current `/collections/:handle` route URL) literally — `formProps()` only wires the submit handler (see the SKILL.md UI rule). Render the filter sidebar, sorting, product grid, and pagination within this browse form so every browse control participates in the same GET submission.
+
+Render the filter sidebar unconditionally — an empty filter list is fine. Place a `noscript` submit button labeled "Apply filters" immediately after the filter sidebar heading; it doubles as the no-JS submit control for sorting.
+
+This structure preserves no-JS filtering and sorting while keeping the form focused on browse controls.
Use uncontrolled form controls. When a route needs to remount checkboxes after external navigation, put `key={serializeCollectionParams({ filters: state.filters, sortKey: undefined, reverse: false }).toString()}` on the filter subtree (for search, include the term in the key) — keyed by serialized **filter state**, not the live URL. The URL clears before the `CollectionProvider` reconciler settles `state.filters`, so a URL-keyed remount bakes in stale `defaultChecked`. This resets checkbox DOM state without coupling active filter chips to the form remount.
+Keep all collection filter controls enabled while `state.status === "loading"`. Apply pending styles to numeric metadata that describes the pending results, including the displayed result count and the available-item count beside each filter value. Pass the loading state as a separate `countPending` prop for filter counts.
+
+Use a persistent, visually hidden status region to announce that product and filter counts are updating. The refreshed displayed count remains a polite live region.
+
+Keep the collection sort select enabled and visually unchanged while loading; its options and selected value remain current while product data refreshes.
+
+## Pagination
+
+Render pagination as native GET links carrying `before` or `after` cursors, per the SKILL.md UI rules. The hydrated enhancement is framework-specific: `react-router.md` documents an accumulated product window driven by `useFetcher`, and `nextjs.md` documents cursor links with a server refresh.
+
## Filters
For each Storefront `FilterValue`, treat `value.input` as the canonical JSON-encoded `ProductFilter`. Convert it to checkbox params by parsing the JSON, wrapping it in the minimal collection state shape, and calling `serializeCollectionParams(...)`. Use Hydrogen helpers for active checks:
@@ -166,25 +207,31 @@ function filterValueInputParamEntries(input: string): Array<{ name: string; valu
);
}
-function FilterValueInput({ activeFilters, filter, value }: Props) {
+function FilterValueInput({ activeFilters, countPending, filter, value }: Props) {
const entries = filterValueInputParamEntries(value.input);
if (entries.length !== 1) return null;
const [{ name, value: paramValue }] = entries;
return (
- {
- if (isMutuallyExclusive(filter) && event.currentTarget.checked) {
- uncheckSiblings(event.currentTarget);
- }
- requestFormSubmit(event);
- }}
- />
+
);
}
```
@@ -199,14 +246,12 @@ function ActiveFilterChip({ collectionPath, filter, state }: Props) {
const removal = getFilterRemovalUrl(currentParams, filter);
const href = removal === "?" ? collectionPath : `${collectionPath}${removal}`;
- return (
-
- {describeFilter(filter)}
-
- );
+ return {describeFilter(filter)};
}
```
+Render chips and the clear-filter link as real anchors for the non-JavaScript fallback. When hydrated, use the framework's scroll-preserving client navigation (see the framework references) so removal keeps the buyer's place in the product grid.
+
When passing `browse.filters` into a `gql()` query variable typed from Storefront API introspection, match the app's established pattern. The Hydrogen examples cast the parsed filters to the generated Storefront API `ProductFilter` type at the query variable boundary:
```ts
@@ -227,8 +272,10 @@ Use the same binding with a synthetic handle:
```tsx
navigate({ search }, { replace: searchParams.size > 0 })}
+ urlSearch={urlSearch}
+ onChange={(search) => {
+ // Same navigation contract as the Provider section.
+ }}
>
diff --git a/packages/hydrogen/skills/hydrogen-setup/steps/4-collection-and-search.md b/packages/hydrogen/skills/hydrogen-setup/steps/4-collection-and-search.md
index 4cb1824a64..c24af20d04 100644
--- a/packages/hydrogen/skills/hydrogen-setup/steps/4-collection-and-search.md
+++ b/packages/hydrogen/skills/hydrogen-setup/steps/4-collection-and-search.md
@@ -5,9 +5,15 @@ Invoke the `hydrogen-collection-browser` skill when adding collection routes, se
## Continue when
- [ ] Filtering and sorting update the URL without scroll reset when hydrated.
+- [ ] All stale numeric result metadata shows pending styles while collection filter controls remain interactive (for example, result totals and available-item counts).
+- [ ] The collection sort select remains interactive and visually unchanged while loading.
- [ ] Reloading the filtered URL server-renders the same filtered state.
- [ ] With JavaScript disabled, checking filters and submitting the form loads the filtered URL.
- [ ] With JavaScript disabled, the load-more / pagination link loads the next page server-side.
-- [ ] Active filter chips remove only one filter and preserve unrelated params.
+- [ ] With JavaScript enabled, Load more appends products to the current collection results.
+- [ ] Hydrated pagination updates the shareable cursor URL while retaining accumulated products.
+- [ ] Load previous appears before the collection results; Load more appears after them.
+- [ ] Filtering or sorting from a cursor URL starts from the first page without `before` or `after`.
+- [ ] Active filter chips remove only one filter, preserve unrelated params, and do not reset scroll when hydrated.
- [ ] Searching with a search term does not erase the search term (preserves `q` search param).
-- [ ] Back/forward navigation settles loading state.
\ No newline at end of file
+- [ ] Back/forward navigation settles loading state.
diff --git a/packages/hydrogen/skills/hydrogen-setup/steps/6-product-detail-page.md b/packages/hydrogen/skills/hydrogen-setup/steps/6-product-detail-page.md
index a84efb08e5..8e8df5dee1 100644
--- a/packages/hydrogen/skills/hydrogen-setup/steps/6-product-detail-page.md
+++ b/packages/hydrogen/skills/hydrogen-setup/steps/6-product-detail-page.md
@@ -186,7 +186,7 @@ if (!data?.product) {
## UI
- You must invoke the `hydrogen-variant-form` skill for option controls, add-to-cart form structure, URL selection, combined listings, price display, disabled states, sold-out states, cart error display, and user acceptance tests. Do not duplicate its rules or invent separate variant matrix logic.
-- When the cart drawer is configured with the canonical anchor trigger, follow the `hydrogen-cart-drawer` skill's guidance and open optimistic state via `formProps({ beforeSubmit: openCartDrawer })`; the samples below use `beforeSubmit`.
+- When the cart drawer is configured, follow the `hydrogen-cart-drawer` skill's trigger guidance and open optimistic state via `formProps({ beforeSubmit: openCartDrawer })`; the samples below use `beforeSubmit`.
- Product route loaders and server helpers may read env indirectly through server-only client/config modules. Product client components must not read `process.env`, `import.meta.env`, or framework env modules.
### React
diff --git a/packages/hydrogen/skills/hydrogen-setup/steps/8-cart-drawer-and-navbar.md b/packages/hydrogen/skills/hydrogen-setup/steps/8-cart-drawer-and-navbar.md
index abda225a8a..4663ae8a9c 100644
--- a/packages/hydrogen/skills/hydrogen-setup/steps/8-cart-drawer-and-navbar.md
+++ b/packages/hydrogen/skills/hydrogen-setup/steps/8-cart-drawer-and-navbar.md
@@ -23,7 +23,7 @@ Create or update the shared site navigation. Do this after the cart drawer, so t
- Preserve the app's existing layout component and styling conventions. Do not remove existing navigation items unless they directly conflict with the setup.
- Ensure a home link exists, if not, make one and point to `/`.
- Every navbar link must resolve: the route must exist in the app, and dynamic destinations (collection or page handles) must exist in the shop. Do not invent handles. In particular, do not link to `/collections/all` — the "all" collection is a Liquid storefront convention with no Storefront API equivalent; `collection(handle: "all")` returns null and the route 404s unless the merchant explicitly created a collection with that handle. For a browse-everything destination link to the `/collections` listing route or `/search`; for specific collections use handles returned by the Storefront API (e.g. from the home page collections query).
-- The cart trigger is a `/cart` anchor that opens the drawer via `showModal()` after hydration; follow the `hydrogen-cart-drawer` skill for the markup.
+- The cart trigger remains a real `/cart` link and follows the `hydrogen-cart-drawer` skill's progressive-enhancement contract after hydration.
- Make `/cart` reachable as a real link in the **footer** (site chrome). `/cart` is the full-page fallback when the cart drawer is unavailable. For strict no-JS live cart HTML, the cart route must receive resolved cart `initialData`.
- Use the framework's native link component when one is already used in the app.
- Keep the navbar server-renderable unless the app already uses a client-only navigation shell.
diff --git a/packages/hydrogen/skills/hydrogen-smoke-test/SKILL.md b/packages/hydrogen/skills/hydrogen-smoke-test/SKILL.md
index ef09d20f6d..60ace2fce9 100644
--- a/packages/hydrogen/skills/hydrogen-smoke-test/SKILL.md
+++ b/packages/hydrogen/skills/hydrogen-smoke-test/SKILL.md
@@ -45,7 +45,7 @@ Product page:
Cart:
- [ ] /cart works without JavaScript and is reachable via a real /cart link in the footer
-- [ ] Header cart trigger is a `/cart` anchor that opens the drawer via `showModal()` after hydration
+- [ ] Header cart trigger remains a `/cart` link and opens the drawer on normal activation after hydration
- [ ] window.Shopify.actions.openCart() opens the drawer after Standard Actions loads
- [ ] A mutation driven through the real Add to cart UI or framework cart action issues `Set-Cookie` and propagates expected response headers
- [ ] Add-to-cart can open the drawer after a successful submit when the product UX chooses that behavior
@@ -58,7 +58,7 @@ Cart:
Collection and search:
- [ ] Collection filters and sort update the URL and product grid
- [ ] Reloading a filtered URL server-renders the same filter/sort state
-- [ ] Active filter chips remove one filter and preserve unrelated params
+- [ ] Active filter chips remove one filter, preserve unrelated params, and do not reset scroll when hydrated
- [ ] Search filters preserve q
- [ ] JavaScript-disabled filter forms still submit with GET
- [ ] Back/forward navigation does not leave browse state stuck in loading
@@ -159,7 +159,7 @@ Expected: a redirect whose `location` header points at Shopify's hosted login, n
## Cart
- `/cart` works without JavaScript and is reachable via a real `/cart` link in the footer.
-- The header cart trigger is a `/cart` anchor that opens the drawer via `showModal()` after hydration.
+- The header cart trigger remains a `/cart` link and opens the drawer on normal activation after hydration.
- `window.Shopify.actions.openCart()` opens the drawer after Standard Actions loads.
- Exercise a mutation through the real Add to cart UI or the framework's cart action. Do not hand-craft an internal
Hydrogen cart payload. Confirm the response issues the expected cart or session `Set-Cookie` and propagates the
@@ -177,7 +177,7 @@ Expected: a redirect whose `location` header points at Shopify's hosted login, n
- Collection filters and sort update the URL and product grid.
- Reloading a filtered URL server-renders the same filter/sort state.
-- Active filter chips remove one filter and preserve unrelated params.
+- Active filter chips remove one filter, preserve unrelated params, and do not reset scroll when hydrated.
- Search filters preserve `q`.
- JavaScript-disabled filter forms still submit with GET.
- Back/forward navigation does not leave browse state stuck in loading.
diff --git a/packages/hydrogen/src/core/collection/__tests__/url.test.ts b/packages/hydrogen/src/core/collection/__tests__/url.test.ts
index 5a4ffd81a4..e6480c0240 100644
--- a/packages/hydrogen/src/core/collection/__tests__/url.test.ts
+++ b/packages/hydrogen/src/core/collection/__tests__/url.test.ts
@@ -707,7 +707,9 @@ describe("collectionSearchEqual", () => {
describe("mergeCollectionParams", () => {
it("replaces store-owned keys while preserving others", () => {
- const existing = new URLSearchParams("grid=3&filter.p.tag=men&sort_by=price-ascending");
+ const existing = new URLSearchParams(
+ "grid=3&before=previous&after=next&filter.p.tag=men&sort_by=price-ascending",
+ );
const merged = mergeCollectionParams(existing, {
filters: [{ tag: "women" }],
@@ -719,6 +721,8 @@ describe("mergeCollectionParams", () => {
expect(merged.get("filter.p.tag")).toBe("women");
expect(merged.get("sort_by")).toBe("title-descending");
expect(merged.getAll("filter.p.tag")).not.toContain("men");
+ expect(merged.has("before")).toBe(false);
+ expect(merged.has("after")).toBe(false);
});
});
diff --git a/packages/hydrogen/src/core/collection/url.ts b/packages/hydrogen/src/core/collection/url.ts
index 12bd62bb84..0d36b1833a 100644
--- a/packages/hydrogen/src/core/collection/url.ts
+++ b/packages/hydrogen/src/core/collection/url.ts
@@ -15,6 +15,9 @@ export interface CollectionParams {
const SORT_BY_DESCENDING_SUFFIX = "-descending";
const SORT_BY_ASCENDING_SUFFIX = "-ascending";
+/** Storefront API pagination cursor params cleared when browse intent changes. */
+const PAGINATION_CURSOR_PARAMS: readonly string[] = ["before", "after"];
+
const SORT_KEY_TO_SORT_BY: Record = {
BEST_SELLING: "best-selling",
CREATED: "created",
@@ -97,7 +100,9 @@ export function normalizeCollectionSearch(search: string): string {
/**
* Merges store-owned params from `state` into `existing`, preserving
- * non-store keys (e.g. `grid`, `view`) already present in the URL.
+ * unrelated keys (e.g. `grid`, `view`) already present in the URL. Collection
+ * pagination cursors (`before`, `after`) are cleared so changed browse intent
+ * starts from the first page.
*/
export function mergeCollectionParams(
existing: URLSearchParams,
@@ -106,7 +111,7 @@ export function mergeCollectionParams(
const merged = new URLSearchParams(existing);
for (const key of Array.from(merged.keys())) {
- if (isStoreOwnedParam(key)) {
+ if (isStoreOwnedParam(key) || PAGINATION_CURSOR_PARAMS.includes(key)) {
merged.delete(key);
}
}
diff --git a/packages/storefront-e2e/specs/cart/cart.spec.ts b/packages/storefront-e2e/specs/cart/cart.spec.ts
index ec970275d4..ca7abb82a8 100644
--- a/packages/storefront-e2e/specs/cart/cart.spec.ts
+++ b/packages/storefront-e2e/specs/cart/cart.spec.ts
@@ -255,7 +255,12 @@ async function openCartOverlayFor(page: Page, productTitle: string): Promise false)) return line;
- await page.getByRole("button", { name: CART_CONTROL_NAME }).first().click();
+ // The cart trigger may be a