diff --git a/.changeset/metal-wasps-collect.md b/.changeset/metal-wasps-collect.md
new file mode 100644
index 0000000000..14734e46a5
--- /dev/null
+++ b/.changeset/metal-wasps-collect.md
@@ -0,0 +1,9 @@
+---
+'@shopify/hydrogen': minor
+---
+
+Added namespace support to prevent conflicts when using multiple Pagination components:
+- New optional `namespace` prop for the `` component
+- New optional `namespace` option for `getPaginationVariables()` utility
+- When specified, pagination URL parameters are prefixed with the namespace (e.g., `products_cursor` instead of `cursor`)
+- Maintains backwards compatibility when no namespace is provided
diff --git a/packages/hydrogen/src/pagination/Pagination.doc.ts b/packages/hydrogen/src/pagination/Pagination.doc.ts
index 1151127506..c81a339bf0 100644
--- a/packages/hydrogen/src/pagination/Pagination.doc.ts
+++ b/packages/hydrogen/src/pagination/Pagination.doc.ts
@@ -38,6 +38,35 @@ const data: ReferenceEntityTemplateSchema = {
description: '',
},
],
+ examples: {
+ description: 'Other examples using the `Pagination` component.',
+ exampleGroups: [
+ {
+ title: 'Multiple `Pagination` components on a single page',
+ examples: [
+ {
+ description:
+ 'Use the `namespace` prop to differentiate between multiple `Pagination` components on a single page',
+ codeblock: {
+ title: 'Example',
+ tabs: [
+ {
+ title: 'JavaScript',
+ code: './Pagination.multiple.example.jsx',
+ language: 'jsx',
+ },
+ {
+ title: 'TypeScript',
+ code: './Pagination.multiple.example.tsx',
+ language: 'tsx',
+ },
+ ],
+ },
+ },
+ ],
+ },
+ ],
+ },
};
export default data;
diff --git a/packages/hydrogen/src/pagination/Pagination.multiple.example.jsx b/packages/hydrogen/src/pagination/Pagination.multiple.example.jsx
new file mode 100644
index 0000000000..11e0357633
--- /dev/null
+++ b/packages/hydrogen/src/pagination/Pagination.multiple.example.jsx
@@ -0,0 +1,117 @@
+import {json} from '@shopify/remix-oxygen';
+import {useLoaderData, Link} from '@remix-run/react';
+import {getPaginationVariables, Pagination} from '@shopify/hydrogen';
+
+export async function loader({request, context: {storefront}}) {
+ const womensPaginationVariables = getPaginationVariables(request, {
+ pageBy: 2,
+ namespace: 'womens', // Specify a unique namespace for the pagination parameters
+ });
+ const mensPaginationVariables = getPaginationVariables(request, {
+ pageBy: 2,
+ namespace: 'mens', // Specify a unique namespace for the pagination parameters
+ });
+
+ const [womensProducts, mensProducts] = await Promise.all([
+ storefront.query(COLLECTION_PRODUCTS_QUERY, {
+ variables: {...womensPaginationVariables, handle: 'women'},
+ }),
+ storefront.query(COLLECTION_PRODUCTS_QUERY, {
+ variables: {...mensPaginationVariables, handle: 'men'},
+ }),
+ ]);
+
+ return json({womensProducts, mensProducts});
+}
+
+export default function Collection() {
+ const {womensProducts, mensProducts} = useLoaderData();
+ return (
+
+
Womens
+
+
+ {({nodes, isLoading, PreviousLink, NextLink}) => {
+ return (
+
+
+ {isLoading ? 'Loading...' : ↑ Load previous}
+
+
+ {nodes.map((product) => (
+
+
+ {product.title}
+
+
+ ))}
+
+
+ {isLoading ? 'Loading...' : Load more ↓}
+
+
+ );
+ }}
+
+
+
Mens
+
+ {({nodes, isLoading, PreviousLink, NextLink}) => {
+ return (
+
+
+ {isLoading ? 'Loading...' : ↑ Load previous}
+
+
+ {nodes.map((product) => (
+
+
+ {product.title}
+
+
+ ))}
+
+
+ {isLoading ? 'Loading...' : Load more ↓}
+
+
+ );
+ }}
+
+
+ );
+}
+
+const COLLECTION_PRODUCTS_QUERY = `#graphql
+ query CollectionProducts(
+ $first: Int
+ $last: Int
+ $startCursor: String
+ $endCursor: String
+ $handle: String!
+ ) {
+ collection(handle: $handle) {
+ products(first: $first, last: $last, before: $startCursor, after: $endCursor) {
+ nodes {
+ id
+ handle
+ title
+ }
+ pageInfo {
+ hasPreviousPage
+ hasNextPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+ }
+`;
diff --git a/packages/hydrogen/src/pagination/Pagination.multiple.example.tsx b/packages/hydrogen/src/pagination/Pagination.multiple.example.tsx
new file mode 100644
index 0000000000..e2261f8d2f
--- /dev/null
+++ b/packages/hydrogen/src/pagination/Pagination.multiple.example.tsx
@@ -0,0 +1,121 @@
+import {json, type LoaderFunctionArgs} from '@shopify/remix-oxygen';
+import {useLoaderData, Link} from '@remix-run/react';
+import {getPaginationVariables, Pagination} from '@shopify/hydrogen';
+import {type Collection} from '@shopify/hydrogen-react/storefront-api-types';
+
+export async function loader({
+ request,
+ context: {storefront},
+}: LoaderFunctionArgs) {
+ const womensPaginationVariables = getPaginationVariables(request, {
+ pageBy: 2,
+ namespace: 'womens', // Specify a unique namespace for the pagination parameters
+ });
+ const mensPaginationVariables = getPaginationVariables(request, {
+ pageBy: 2,
+ namespace: 'mens', // Specify a unique namespace for the pagination parameters
+ });
+
+ const [womensProducts, mensProducts] = await Promise.all([
+ storefront.query<{collection: Collection}>(COLLECTION_PRODUCTS_QUERY, {
+ variables: {...womensPaginationVariables, handle: 'women'},
+ }),
+ storefront.query<{collection: Collection}>(COLLECTION_PRODUCTS_QUERY, {
+ variables: {...mensPaginationVariables, handle: 'men'},
+ }),
+ ]);
+
+ return json({womensProducts, mensProducts});
+}
+
+export default function Collection() {
+ const {womensProducts, mensProducts} = useLoaderData();
+ return (
+
+
Womens
+
+
+ {({nodes, isLoading, PreviousLink, NextLink}) => {
+ return (
+
+
+ {isLoading ? 'Loading...' : ↑ Load previous}
+
+
+ {nodes.map((product) => (
+
+
+ {product.title}
+
+
+ ))}
+
+
+ {isLoading ? 'Loading...' : Load more ↓}
+
+
+ );
+ }}
+
+
+
Mens
+
+ {({nodes, isLoading, PreviousLink, NextLink}) => {
+ return (
+
+
+ {isLoading ? 'Loading...' : ↑ Load previous}
+
+
+ {nodes.map((product) => (
+
+
+ {product.title}
+
+
+ ))}
+
+
+ {isLoading ? 'Loading...' : Load more ↓}
+
+
+ );
+ }}
+
+
+ );
+}
+
+const COLLECTION_PRODUCTS_QUERY = `#graphql
+ query CollectionProducts(
+ $first: Int
+ $last: Int
+ $startCursor: String
+ $endCursor: String
+ $handle: String!
+ ) {
+ collection(handle: $handle) {
+ products(first: $first, last: $last, before: $startCursor, after: $endCursor) {
+ nodes {
+ id
+ handle
+ title
+ }
+ pageInfo {
+ hasPreviousPage
+ hasNextPage
+ startCursor
+ endCursor
+ }
+ }
+ }
+ }
+` as const;
diff --git a/packages/hydrogen/src/pagination/Pagination.ts b/packages/hydrogen/src/pagination/Pagination.ts
index 2303374e85..c480022eca 100644
--- a/packages/hydrogen/src/pagination/Pagination.ts
+++ b/packages/hydrogen/src/pagination/Pagination.ts
@@ -4,6 +4,7 @@ import {
useMemo,
useRef,
forwardRef,
+ useState,
type Ref,
type FC,
} from 'react';
@@ -41,8 +42,12 @@ type Connection =
};
type PaginationState = {
- nodes?: Array;
- pageInfo?: PageInfo | null;
+ pagination?: {
+ [key: string]: {
+ nodes: Array;
+ pageInfo?: PageInfo | null;
+ };
+ };
};
interface PaginationInfo {
@@ -78,6 +83,8 @@ type PaginationProps = {
connection: Connection;
/** A render prop that includes pagination data and helpers. */
children: PaginationRenderProp;
+ /** A namespace for the pagination component to avoid URL param conflicts when using multiple `Pagination` components on a single page. */
+ namespace?: string;
};
type PaginationRenderProp = FC>;
@@ -96,9 +103,20 @@ export function Pagination({
console.warn(' requires children to work properly');
return null;
},
+ namespace = '',
}: PaginationProps): ReturnType {
+ const [isLoading, setIsLoading] = useState(false);
const transition = useNavigation();
- const isLoading = transition.state === 'loading';
+ const location = useLocation();
+ const navigate = useNavigate();
+
+ // Reset loading state once the transition state is idle
+ useEffect(() => {
+ if (transition.state === 'idle') {
+ setIsLoading(false);
+ }
+ }, [transition.state]);
+
const {
endCursor,
hasNextPage,
@@ -107,19 +125,33 @@ export function Pagination({
nodes,
previousPageUrl,
startCursor,
- } = usePagination(connection);
+ } = usePagination(connection, namespace);
const state = useMemo(
() => ({
- pageInfo: {
- endCursor,
- hasPreviousPage,
- hasNextPage,
- startCursor,
+ ...location.state,
+ pagination: {
+ ...(location.state?.pagination || {}),
+ [namespace]: {
+ pageInfo: {
+ endCursor,
+ hasPreviousPage,
+ hasNextPage,
+ startCursor,
+ },
+ nodes,
+ },
},
- nodes,
}),
- [endCursor, hasNextPage, hasPreviousPage, startCursor, nodes],
+ [
+ endCursor,
+ hasNextPage,
+ hasPreviousPage,
+ startCursor,
+ nodes,
+ namespace,
+ location.state,
+ ],
);
const NextLink = useMemo(
@@ -136,6 +168,7 @@ export function Pagination({
state,
replace: true,
ref,
+ onClick: () => setIsLoading(true),
})
: null;
}),
@@ -156,6 +189,7 @@ export function Pagination({
state,
replace: true,
ref,
+ onClick: () => setIsLoading(true),
})
: null;
}),
@@ -175,10 +209,24 @@ export function Pagination({
});
}
-function getParamsWithoutPagination(paramsString?: string) {
+function getParamsWithoutPagination(
+ paramsString?: string,
+ state?: PaginationState,
+) {
const params = new URLSearchParams(paramsString);
- params.delete('cursor');
- params.delete('direction');
+
+ // Get all namespaces from state
+ const activeNamespaces = Object.keys(state?.pagination || {});
+
+ activeNamespaces.forEach((namespace) => {
+ // Clean up cursor and direction params for both namespaced and non-namespaced pagination
+ const namespacePrefix = namespace === '' ? '' : `${namespace}_`;
+ const cursorParam = `${namespacePrefix}cursor`;
+ const directionParam = `${namespacePrefix}direction`;
+ params.delete(cursorParam);
+ params.delete(directionParam);
+ });
+
return params.toString();
}
@@ -197,6 +245,7 @@ function makeError(prop: string) {
*/
export function usePagination(
connection: Connection,
+ namespace: string = '',
): Omit<
PaginationInfo,
'isLoading' | 'state' | 'NextLink' | 'PreviousLink'
@@ -224,6 +273,7 @@ export function usePagination(
makeError('pageInfo.hasPreviousPage');
}
+ const transition = useNavigation();
const navigate = useNavigate();
const {state, search, pathname} = useLocation() as {
state?: PaginationState;
@@ -231,46 +281,60 @@ export function usePagination(
pathname?: string;
};
+ const cursorParam = namespace ? `${namespace}_cursor` : 'cursor';
+ const directionParam = namespace ? `${namespace}_direction` : 'direction';
+
const params = new URLSearchParams(search);
- const direction = params.get('direction');
+ const direction = params.get(directionParam);
const isPrevious = direction === 'previous';
const nodes = useMemo(() => {
- if (!globalThis?.window?.__hydrogenHydrated || !state || !state?.nodes) {
+ if (
+ !globalThis?.window?.__hydrogenHydrated ||
+ !state?.pagination?.[namespace]?.nodes
+ ) {
return flattenConnection(connection);
}
if (isPrevious) {
- return [...flattenConnection(connection), ...state.nodes];
+ return [
+ ...flattenConnection(connection),
+ ...(state.pagination[namespace].nodes || []),
+ ];
} else {
- return [...state.nodes, ...flattenConnection(connection)];
+ return [
+ ...(state.pagination[namespace].nodes || []),
+ ...flattenConnection(connection),
+ ];
}
- }, [state, connection]);
+ }, [state, connection, namespace]);
const currentPageInfo = useMemo(() => {
const hydrogenHydrated = globalThis?.window?.__hydrogenHydrated;
+ const stateInfo = state?.pagination?.[namespace]?.pageInfo;
+
let pageStartCursor =
- !hydrogenHydrated || state?.pageInfo?.startCursor === undefined
+ !hydrogenHydrated || stateInfo?.startCursor === undefined
? connection.pageInfo.startCursor
- : state.pageInfo.startCursor;
+ : stateInfo.startCursor;
let pageEndCursor =
- !hydrogenHydrated || state?.pageInfo?.endCursor === undefined
+ !hydrogenHydrated || stateInfo?.endCursor === undefined
? connection.pageInfo.endCursor
- : state.pageInfo.endCursor;
+ : stateInfo.endCursor;
let previousPageExists =
- !hydrogenHydrated || state?.pageInfo?.hasPreviousPage === undefined
+ !hydrogenHydrated || stateInfo?.hasPreviousPage === undefined
? connection.pageInfo.hasPreviousPage
- : state.pageInfo.hasPreviousPage;
+ : stateInfo.hasPreviousPage;
let nextPageExists =
- !hydrogenHydrated || state?.pageInfo?.hasNextPage === undefined
+ !hydrogenHydrated || stateInfo?.hasNextPage === undefined
? connection.pageInfo.hasNextPage
- : state.pageInfo.hasNextPage;
+ : stateInfo.hasNextPage;
- // if (!hydrogenHydrated) {
- if (state?.nodes) {
+ // Update page info based on current connection
+ if (state?.pagination?.[namespace]?.nodes) {
if (isPrevious) {
pageStartCursor = connection.pageInfo.startCursor;
previousPageExists = connection.pageInfo.hasPreviousPage;
@@ -279,7 +343,6 @@ export function usePagination(
nextPageExists = connection.pageInfo.hasNextPage;
}
}
- // }
return {
startCursor: pageStartCursor,
@@ -290,6 +353,7 @@ export function usePagination(
}, [
isPrevious,
state,
+ namespace,
connection.pageInfo.hasNextPage,
connection.pageInfo.hasPreviousPage,
connection.pageInfo.startCursor,
@@ -298,7 +362,7 @@ export function usePagination(
// Keep track of the current URL state, to compare whenever the URL changes
const urlRef = useRef({
- params: getParamsWithoutPagination(search),
+ params: getParamsWithoutPagination(search, state),
pathname,
});
@@ -312,37 +376,42 @@ export function usePagination(
}, []);
useEffect(() => {
+ const currentParams = getParamsWithoutPagination(search, state);
+ const previousParams = urlRef.current.params;
+ const pathChanged = pathname !== urlRef.current.pathname;
+ const nonPaginationParamsChanged = currentParams !== previousParams;
+
if (
- // If the URL changes (independent of pagination params)
- // then reset the pagination params in the URL
- getParamsWithoutPagination(search) !== urlRef.current.params ||
- pathname !== urlRef.current.pathname
+ // Only clean up if the base URL or non-pagination params change
+ (pathChanged || nonPaginationParamsChanged) &&
+ // And we're not on the initial load
+ !(transition.state === 'idle' && !transition.location)
) {
urlRef.current = {
pathname,
- params: getParamsWithoutPagination(search),
+ params: getParamsWithoutPagination(search, state),
};
- navigate(`${pathname}?${getParamsWithoutPagination(search)}`, {
+ navigate(`${pathname}?${getParamsWithoutPagination(search, state)}`, {
replace: true,
preventScrollReset: true,
state: {nodes: undefined, pageInfo: undefined},
});
}
- }, [pathname, search]);
+ }, [pathname, search, state]);
const previousPageUrl = useMemo(() => {
const params = new URLSearchParams(search);
- params.set('direction', 'previous');
+ params.set(directionParam, 'previous');
currentPageInfo.startCursor &&
- params.set('cursor', currentPageInfo.startCursor);
+ params.set(cursorParam, currentPageInfo.startCursor);
return `?${params.toString()}`;
}, [search, currentPageInfo.startCursor]);
const nextPageUrl = useMemo(() => {
const params = new URLSearchParams(search);
- params.set('direction', 'next');
+ params.set(directionParam, 'next');
currentPageInfo.endCursor &&
- params.set('cursor', currentPageInfo.endCursor);
+ params.set(cursorParam, currentPageInfo.endCursor);
return `?${params.toString()}`;
}, [search, currentPageInfo.endCursor]);
@@ -351,13 +420,13 @@ export function usePagination(
/**
* @param request The request object passed to your Remix loader function.
- * @param options Options for how to configure the pagination variables. Includes the ability to change how many nodes are within each page.
+ * @param options Options for how to configure the pagination variables. Includes the ability to change how many nodes are within each page as well as a namespace to avoid URL param conflicts when using multiple `Pagination` components on a single page.
*
* @returns Variables to be used with the `storefront.query` function
*/
export function getPaginationVariables(
request: Request,
- options: {pageBy: number} = {pageBy: 20},
+ options: {pageBy: number; namespace?: string} = {pageBy: 20},
) {
if (typeof request?.url === 'undefined') {
throw new Error(
@@ -365,12 +434,15 @@ export function getPaginationVariables(
);
}
- const {pageBy} = options;
+ const {pageBy, namespace = ''} = options;
const searchParams = new URLSearchParams(new URL(request.url).search);
- const cursor = searchParams.get('cursor') ?? undefined;
+ const cursorParam = namespace ? `${namespace}_cursor` : 'cursor';
+ const directionParam = namespace ? `${namespace}_direction` : 'direction';
+
+ const cursor = searchParams.get(cursorParam) ?? undefined;
const direction =
- searchParams.get('direction') === 'previous' ? 'previous' : 'next';
+ searchParams.get(directionParam) === 'previous' ? 'previous' : 'next';
const isPrevious = direction === 'previous';
const prevPage = {
diff --git a/packages/hydrogen/src/pagination/pagination.test.ts b/packages/hydrogen/src/pagination/pagination.test.ts
index ae5152571a..2b5b1e199a 100644
--- a/packages/hydrogen/src/pagination/pagination.test.ts
+++ b/packages/hydrogen/src/pagination/pagination.test.ts
@@ -63,6 +63,17 @@ describe('getPaginationVariables', () => {
),
).toEqual({startCursor: 'abc', last: 10});
});
+
+ it('returns cursor from search params with namespace', () => {
+ expect(
+ getPaginationVariables(
+ new Request(
+ 'https://localhost:3000?products_cursor=abc&products_direction=previous',
+ ),
+ {pageBy: 20, namespace: 'products'},
+ ),
+ ).toEqual({startCursor: 'abc', last: 20});
+ });
});
describe('', () => {
@@ -224,7 +235,7 @@ describe('', () => {
@@ -258,7 +269,7 @@ describe('', () => {
@@ -310,17 +321,21 @@ describe('', () => {
{
"state": {
- "pageInfo": {
- "endCursor": "abc",
- "hasPreviousPage": true,
- "hasNextPage": false,
- "startCursor": "cde"
- },
- "nodes": [
- 1,
- 2,
- 3
- ]
+ "pagination": {
+ "": {
+ "pageInfo": {
+ "endCursor": "abc",
+ "hasPreviousPage": true,
+ "hasNextPage": false,
+ "startCursor": "cde"
+ },
+ "nodes": [
+ 1,
+ 2,
+ 3
+ ]
+ }
+ }
},
"hasNextPage": false,
"hasPreviousPage": true,
@@ -337,6 +352,184 @@ describe('
', () => {
`);
});
+
+ it('allows multiple Pagination components with unique namespaces', () => {
+ const {asFragment} = render(
+ createElement(
+ Fragment,
+ null,
+ createElement(Pagination, {
+ connection: {
+ nodes: [1, 2, 3],
+ pageInfo: {
+ endCursor: 'abc',
+ startCursor: 'cde',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ },
+ },
+ namespace: 'products',
+ children: ({nodes}) =>
+ createElement(
+ Fragment,
+ null,
+ nodes.map((node) =>
+ createElement(
+ 'div',
+ {key: node as string},
+ `Product: ${node as string}`,
+ ),
+ ),
+ ),
+ }),
+ createElement(Pagination, {
+ connection: {
+ nodes: [4, 5, 6],
+ pageInfo: {
+ endCursor: 'def',
+ startCursor: 'ghi',
+ hasNextPage: false,
+ hasPreviousPage: true,
+ },
+ },
+ namespace: 'orders',
+ children: ({nodes}) =>
+ createElement(
+ Fragment,
+ null,
+ nodes.map((node) =>
+ createElement(
+ 'div',
+ {key: node as string},
+ `Order: ${node as string}`,
+ ),
+ ),
+ ),
+ }),
+ ),
+ );
+
+ expect(asFragment()).toMatchInlineSnapshot(`
+
+
+ Product: 1
+
+
+ Product: 2
+
+
+ Product: 3
+
+
+ Order: 4
+
+
+ Order: 5
+
+
+ Order: 6
+
+
+ `);
+ });
+
+ it('renders multiple Pagination components with unique namespaces correctly', () => {
+ const {asFragment} = render(
+ createElement(
+ Fragment,
+ null,
+ createElement(Pagination, {
+ connection: {
+ nodes: [1, 2, 3],
+ pageInfo: {
+ endCursor: 'abc',
+ startCursor: 'cde',
+ hasNextPage: true,
+ hasPreviousPage: false,
+ },
+ },
+ namespace: 'products',
+ children: ({NextLink, PreviousLink, nodes}) =>
+ createElement(
+ 'div',
+ null,
+ nodes.map((node) =>
+ createElement(
+ 'div',
+ {key: node as string},
+ `Order: ${node as string}`,
+ ),
+ ),
+ createElement(NextLink, null, 'Next'),
+ createElement(PreviousLink, null, 'Previous'),
+ ),
+ }),
+ createElement(Pagination, {
+ connection: {
+ nodes: [4, 5, 6],
+ pageInfo: {
+ endCursor: 'def',
+ startCursor: 'ghi',
+ hasNextPage: false,
+ hasPreviousPage: true,
+ },
+ },
+ namespace: 'orders',
+ children: ({NextLink, PreviousLink, nodes}) =>
+ createElement(
+ 'div',
+ null,
+ nodes.map((node) =>
+ createElement(
+ 'div',
+ {key: node as string},
+ `Order: ${node as string}`,
+ ),
+ ),
+ createElement(NextLink, null, 'Next'),
+ createElement(PreviousLink, null, 'Previous'),
+ ),
+ }),
+ ),
+ );
+
+ expect(asFragment()).toMatchInlineSnapshot(`
+
+
+
+ Order: 1
+
+
+ Order: 2
+
+
+ Order: 3
+
+
+
+
+
+ Order: 4
+
+
+ Order: 5
+
+
+ Order: 6
+
+
+
+
+ `);
+ });
});
function fillLocation(partial: Partial = {}) {