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(` + +
+ + + `); + }); }); function fillLocation(partial: Partial = {}) {