Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
a955123
Refresh React Router Oxygen template
fredericoo Aug 6, 2026
36d69ce
Fix React Router template preview integration
fredericoo Aug 12, 2026
66642c1
Harden React Router request forwarding
fredericoo Aug 12, 2026
768cd08
Fix initial React Router stylesheet loading
fredericoo Aug 12, 2026
3ffca74
Progressively enhance the cart trigger
fredericoo Aug 12, 2026
2dce3fe
Progressively enhance collection pagination
fredericoo Aug 12, 2026
2cf4259
Document why subresource integrity is disabled
fredericoo Aug 13, 2026
c048b62
Simplify cart drawer open helper
fredericoo Aug 13, 2026
111089d
Split collection browser reference by framework
fredericoo Aug 13, 2026
d13333f
Reword loading indication as pending styles
fredericoo Aug 13, 2026
a5b86b3
Name pagination cursor params constant
fredericoo Aug 13, 2026
d721ccf
Treat customer accounts as part of the template
fredericoo Aug 13, 2026
6f24240
Remove example analytics console destination
fredericoo Aug 13, 2026
b97152b
Manage removal focus with React refs
fredericoo Aug 13, 2026
42ff7ad
Use Shopify default consent banner
fredericoo Aug 13, 2026
bb100e0
Merge branch 'preview' into fb-react-router-template-refresh-preview
fredericoo Aug 13, 2026
042ea6c
Source shop name and nav collections from the API
fredericoo Aug 13, 2026
dd0f62c
Add useHydrated hook
fredericoo Aug 13, 2026
0fcca87
Clarify quantity stepper scope
fredericoo Aug 13, 2026
1129408
Drop redundant lib comments
fredericoo Aug 13, 2026
1081938
Require currency code for price filters
fredericoo Aug 13, 2026
12f9525
Drop unit tests from the template
fredericoo Aug 13, 2026
951019c
Rename unavailable customer accounts notice
fredericoo Aug 13, 2026
7617490
Drive the home hero from the first collection
fredericoo Aug 13, 2026
8703593
Defer the cart seed to the cart provider
fredericoo Aug 13, 2026
721a5b0
Format touched template files
fredericoo Aug 13, 2026
53d367f
Extract hero image to satisfy complexity lint
fredericoo Aug 13, 2026
e9225a1
Accept link cart triggers in cart e2e
fredericoo Aug 13, 2026
b8b8fd2
Suspend cart surfaces on the deferred cart seed
fredericoo Aug 13, 2026
7041578
Suppress hydration warning on the inbox chat element
fredericoo Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-cursors-paginate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@shopify/hydrogen": patch
---

Reset `before` and `after` pagination cursors when collection filters or sorting change.
20 changes: 9 additions & 11 deletions packages/hydrogen/skills/hydrogen-cart-drawer/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<a href="/cart">Cart</a>` 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.

Expand Down Expand Up @@ -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 (
<a href="/cart" onClick={(e) => { e.preventDefault(); openCartDrawer(); }} aria-controls={CART_DRAWER_ID} aria-haspopup="dialog">
Cart
</a>
);
```text
without enhancement: <a href="/cart">Cart</a> -> navigate to /cart
with enhancement: <a href="/cart">Cart</a> -> 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).
Expand All @@ -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
<form {...formProps({ beforeSubmit: openCartDrawer })}>
Expand Down Expand Up @@ -217,8 +215,8 @@ See `references/css.md` for the reference drawer shell, entry/exit animation, ba

After building the cart drawer, test:

- [ ] Cart trigger is a `/cart` anchor pre-hydration; after hydration its `onClick` opens the drawer via `showModal()`
- [ ] If a no-JS fallback is required, it navigates to `/cart` without JavaScript
- [ ] Cart trigger remains a `/cart` link; after hydration, normal activation opens the drawer
- [ ] Without JavaScript, the cart trigger navigates to `/cart`
- [ ] `window.Shopify.actions.openCart()` opens drawer (test from browser console)
- [ ] Drawer closes via Escape, backdrop click (with `closedby="any"`), and the close button
- [ ] Focus returns to the cart icon after close
Expand Down
18 changes: 14 additions & 4 deletions packages/hydrogen/skills/hydrogen-collection-browser/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Server data should include:
- `dataSearch`: the exact search string used for the server query.
- `products`: Storefront API product nodes shaped for the product card.
- `availableFilters`: normalized filter metadata from `products.filters` or `search.productFilters`.
- Optional `totalCount` and `pageInfo` for search or pagination UI.
- Optional `totalCount` and cursor-complete `pageInfo` (`startCursor`, `endCursor`, `hasPreviousPage`, and `hasNextPage`) for search or pagination UI.

Use `parseCollectionParams(searchParams)` before Storefront API queries. Pass parsed `filters`, `sortKey`, and `reverse` into `collection.products(...)` or `search(...)`.

Expand All @@ -42,10 +42,14 @@ Use `parseCollectionParams(searchParams)` before Storefront API queries. Pass pa
- Use the framework binding when a matching reference exists. Otherwise, use the core store directly. Do not hand-roll browse state with component state.
- The browse form must carry both `method="get"` **and** an explicit `action` (the collection/search route URL, e.g. `action="/collections/shoes"` or the search route) so filters and sort degrade to a real GET submit without JavaScript. `formProps()` only wires the submit handler — it does not set `method` or `action` — so render both literally; the helper cannot infer the route.
- Use `formProps()` on the browse form: spread it, then add the literal `method="get"` and `action`. On hydrated changes, call `form.requestSubmit()` for **checkboxes and `<select>`**. For **text/number inputs (price min/max)** use `onBlur` + `onKeyDown` Enter instead — `onChange` fires per keystroke and would submit the GET form (and re-query Storefront) on every character.
- Render a `noscript` submit button for filter sidebars that auto-submit when hydrated.
- Render "load more" / pagination as a GET link (the framework's link component) carrying the next-page cursor (e.g. `?after=<endCursor>`), so it works without JavaScript. Hydration may upgrade it to append-in-place; the bare link must still load the next page server-side (it replaces the page rather than appending when JS is off).
- Provide a native submit control for every auto-submitting browse form so filtering and sorting remain usable without JavaScript.
- For collection routes, render pagination as native GET links carrying `before` or `after` cursors. Place "Load previous" immediately before the results list and "Load more" after it. After hydrated pagination succeeds, push the cursor URL into browser history while preserving the accumulated products. This keeps each cursor URL shareable: opening or reloading it starts at that cursor and exposes the available previous/next links. Preserve native navigation for JavaScript-disabled and modified-click flows. Apply the framework reference's hydrated enhancement when one is provided.
- Start every filter or sort change from the first page. Hydrogen's collection reconciler clears `before` and `after` during hydrated browse changes. Native GET forms use the base collection/search route as their explicit `action` and submit filter/sort controls without cursor fields.
- Keep all collection filter controls interactive while browse data is loading. Apply pending styles to stale numeric result metadata, including the displayed result count and each filter value's available-item count, until fresh data arrives.
- Keep the collection sort select interactive and visually unchanged while browse data is loading.
- Show stale products with a pending visual state while `state.status === "loading"`; do not replace the grid with a skeleton.
- Serialize active filter chips from `serializeCollectionParams(state)` and remove filters with `getFilterRemovalUrl(...)`.
- Preserve the current scroll position when hydrated active-filter chips or clear-filter links navigate. Keep native links as the non-JavaScript fallback, and use the framework reference's scroll-preserving client navigation option when one is provided.
- Use `isFilterInputActive(state.filters, value.input)` to mark checked filter inputs.
- Treat each Storefront API `FilterValue.input` JSON string as the authoritative filter identity. To render one checkbox, parse that JSON into a `ProductFilter`, wrap it as `{ filters: [filter], sortKey: undefined, reverse: false }`, and pass it to `serializeCollectionParams(...)` for the field name/value. Do not derive filter shapes or param names from filter IDs, labels, or types.
- Build sort option values with `getSortByValue(...)`; it emits the Liquid-compatible `sort_by` strings that `parseCollectionParams()` understands.
Expand Down Expand Up @@ -75,6 +79,12 @@ Use `parseCollectionParams(searchParams)` before Storefront API queries. Pass pa
- 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, collection pagination appends or prepends unique products and keeps the current results visible.
- Successful hydrated pagination pushes the current `before` or `after` cursor into a shareable browser URL.
- Direct cursor URLs render a link to the previous page when `hasPreviousPage` is true.
- Filter and sort changes clear `before` and `after`.
- 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.
- Active filter chips remove only one filter, preserve unrelated params, and do not reset scroll when hydrated.
- Search filters preserve `q`.
- Back/forward navigation settles loading state.
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,33 @@ function filterValueInputParamEntries(input: string): Array<{ name: string; valu

This helper is only an adapter from Storefront API `FilterValue.input` to Hydrogen's serializer. Do not replace it with an app-owned filter mapping table.

## Pagination

Render pagination as native cursor links; the server page re-queries at the cursor and renders that page's products:

```tsx
function LoadMore({
pageInfo,
collectionPath,
onNavigate,
}: {
pageInfo: PageInfo;
collectionPath: string;
onNavigate: () => void;
}) {
if (!pageInfo.hasNextPage) return null;
const href = `${collectionPath}?after=${encodeURIComponent(pageInfo.endCursor ?? "")}`;

return (
<Link href={href} onClick={onNavigate}>
Load more
</Link>
);
}
```

Pass `onNavigate={() => router.refresh()}` so the React Server Component payload catches up with the changed `searchParams` (see the `router.refresh()` note above). Use a `before` cursor link for "Load previous" when `hasPreviousPage` is true. Each cursor URL stays shareable: opening or reloading it server-renders that page with its available previous/next links, and the plain anchor remains the no-JS fallback.

## Search Pages

Use the same component with a discriminated `mode`:
Expand All @@ -109,7 +136,7 @@ For search forms:

## Links And Clear URLs

Use `next/link` for clear links and active filter chips. If a removal URL is `"?"`, link to the base pathname. For search pages, preserve `q` when removing filters.
Use `next/link` for clear links and active filter chips, with `scroll={false}` so client navigation keeps the buyer's current scroll position. If a removal URL is `"?"`, link to the base pathname. For search pages, preserve `q` when removing filters.

## Gotchas

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
# React Router

Route-level wiring for the shared React binding in `react.md`. Read that file first for the provider contract, browse form, filters, and search rules.

## Contents

- Loader
- Provider
- Progressive Pagination
- Links

## Loader

In a route loader, call the server data function from `react.md` and translate failures into route responses. For hydrated pagination requests (marked with `_pagination=1`), return a structured error instead of throwing so the client can keep the accumulated products rendered:

```ts
export async function loader({ context, params, request }: Route.LoaderArgs) {
const storefrontClient = context.get(storefrontClientContext);
const url = new URL(request.url);
const browse = parseCollectionParams(url.searchParams);
const browseSearch = serializeCollectionParams(browse).toString();
const dataSearch = url.searchParams.toString();
const isPaginationRequest = url.searchParams.get("_pagination") === "1";
const before = url.searchParams.get("before") || undefined;
const after = before ? undefined : url.searchParams.get("after") || undefined;

const queryResult = await storefrontClient
.graphql(COLLECTION_QUERY, {
variables: {
handle: params.handle,
first: before ? undefined : 24,
last: before ? 24 : undefined,
before,
after,
filters:
browse.filters.length > 0 ? (browse.filters as StorefrontApiProductFilter[]) : undefined,
sortKey: browse.sortKey,
reverse: browse.reverse || undefined,
},
})
.catch((error: unknown) => {
if (isPaginationRequest) return null;
throw error;
});

if (!queryResult || queryResult.errors) {
if (isPaginationRequest) return paginationErrorLoaderData(browseSearch, dataSearch);
throw new Response("Collection query failed", { status: 500 });
}

const { data } = queryResult;

if (!data?.collection) {
if (isPaginationRequest) return paginationErrorLoaderData(browseSearch, dataSearch);
throw new Response("Collection not found", { status: 404 });
}

return {
collection: data.collection,
products: data.collection.products.nodes,
pageInfo: data.collection.products.pageInfo,
availableFilters: data.collection.products.filters,
browseSearch,
dataSearch,
paginationError: false as const,
};
}

function paginationErrorLoaderData(browseSearch: string, dataSearch: string) {
return {
collection: null,
products: [],
pageInfo: null,
availableFilters: [],
browseSearch,
dataSearch,
paginationError: true as const,
};
}
```

## Provider

Wire the `react.md` provider contract with React Router navigation:

```tsx
export default function CollectionRoute({ loaderData }: Route.ComponentProps) {
if (loaderData.paginationError || !loaderData.collection || !loaderData.pageInfo) {
throw new Response("Collection query failed", { status: 500 });
}

const navigate = useNavigate();
const [searchParams] = useSearchParams();

return (
<CollectionProvider
data={{ handle: loaderData.collection.handle, dataSearch: loaderData.dataSearch }}
urlSearch={searchParams.toString()}
onChange={(search) =>
navigate(
{ search },
{
replace: searchParams.size > 0,
preventScrollReset: true,
},
)
}
>
<CollectionPage {...loaderData} />
</CollectionProvider>
);
}
```

For search pages, use the synthetic handle from `react.md` with the same `onChange`:

```tsx
<CollectionProvider
data={{ handle: `search:${term}`, dataSearch }}
urlSearch={searchParams.toString()}
onChange={(search) => navigate({ search }, { replace: searchParams.size > 0 })}
>
<input type="hidden" name="q" value={term} />
</CollectionProvider>
```

## Progressive Pagination

Query both cursor directions and all four page boundaries. Use `first` plus `after` for forward pages, and `last` plus `before` for previous pages. Return a normalized `browseSearch` containing only serialized filters and sort; use it as the accumulated product window's reset identity.

Render the "Load previous" anchor immediately before the results list and the "Load more" anchor after it. Enhance ordinary hydrated clicks with `useFetcher<typeof loader>().load(href)`. Forward pages append products and advance `endCursor` plus `hasNextPage`; previous pages prepend products and advance `startCursor` plus `hasPreviousPage`. Deduplicate by product ID.

After merging a successful page, push its cursor URL with React Router's `navigate(..., {defaultShouldRevalidate: false, preventScrollReset: true})`. With React Router's default revalidation behavior, the URL changes without rerunning the loader or replacing the accumulated window. A route-level `shouldRevalidate` should return its supplied default for this pagination navigation. A reload or shared link starts at that cursor and renders the available previous/next links.

After each successful load, focus the first added product link. Keep a persistent showing-count live region so assistive technology also announces the larger product count. While filters or sorting are pending, pause pagination controls until the loader's `browseSearch` matches the live serialized browse state.

```tsx
const pagination = useFetcher<typeof loader>();
const navigate = useNavigate();
const [productWindow, setProductWindow] = useState(() => ({ products, pageInfo }));

useEffect(() => {
const page = pagination.data;
if (
!page ||
page.browseSearch !== loaderBrowseSearch ||
page.browseSearch !== currentBrowseSearch
) {
return;
}

if (page.paginationError) {
setPaginationError(true);
return;
}

const isPrevious = new URLSearchParams(page.dataSearch).has("before");

setProductWindow((current) => ({
products: mergeUniqueProducts(current.products, page.products, isPrevious),
pageInfo: isPrevious
? {
...current.pageInfo,
startCursor: page.pageInfo.startCursor,
hasPreviousPage: page.pageInfo.hasPreviousPage,
}
: {
...current.pageInfo,
endCursor: page.pageInfo.endCursor,
hasNextPage: page.pageInfo.hasNextPage,
},
}));

const shareableParams = new URLSearchParams(page.dataSearch);
shareableParams.delete("_pagination");
void navigate(
{ search: `?${shareableParams.toString()}` },
{ defaultShouldRevalidate: false, preventScrollReset: true },
);
}, [currentBrowseSearch, loaderBrowseSearch, navigate, pagination.data]);

<a
href={nextPageUrl}
onClick={(event) => {
if (!isPlainLeftClick(event)) return;
event.preventDefault();
if (pagination.state === "idle") pagination.load(`${nextPageUrl}&_pagination=1`);
}}
>
{pagination.state === "idle" ? "Load more" : "Loading products..."}
</a>;
```

Set the collection page component's `key` from the loader's `browseSearch` so a completed filter or sort navigation creates a fresh product window. Rebase the window when authoritative loader products change under the same browse identity. Compare fetcher data against the live serialized browse state before merging so an older pagination response cannot add products after the buyer changes filters or sorting.

For enhanced pagination requests, return a structured pagination error from the loader. Keep existing products rendered, announce the inline error with `role="alert"`, and preserve the cursor anchor as the retry control. Native anchor navigations continue to use the route's normal error boundary.

## Links

Use `<Link to={href} preventScrollReset>` for active-filter chips and the clear-filter link so client navigation keeps the buyer's place in the product grid. `Link` still renders an anchor for the non-JavaScript fallback.
Loading
Loading