skeleton@2024.10.2
Patch Changes
-
Remove initial redirect from product display page (#2643) by @scottdixon
-
Optional updates for the product route and product form to handle combined listing and 2000 variant limit. (#2659) by @wizardlyhel
- Update your SFAPI product query to bring in the new query fields:
const PRODUCT_FRAGMENT = `#graphql fragment Product on Product { id title vendor handle descriptionHtml description + encodedVariantExistence + encodedVariantAvailability options { name optionValues { name + firstSelectableVariant { + ...ProductVariant + } + swatch { + color + image { + previewImage { + url + } + } + } } } - selectedVariant: selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) { + selectedOrFirstAvailableVariant(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) { + ...ProductVariant + } + adjacentVariants (selectedOptions: $selectedOptions) { + ...ProductVariant + } - variants(first: 1) { - nodes { - ...ProductVariant - } - } seo { description title } } ${PRODUCT_VARIANT_FRAGMENT} ` as const;
- Update
loadDeferredData
function. We no longer need to load in all the variants. You can also removeVARIANTS_QUERY
variable.
function loadDeferredData({context, params}: LoaderFunctionArgs) { + // Put any API calls that is not critical to be available on first page render + // For example: product reviews, product recommendations, social feeds. - // In order to show which variants are available in the UI, we need to query - // all of them. But there might be a *lot*, so instead separate the variants - // into it's own separate query that is deferred. So there's a brief moment - // where variant options might show as available when they're not, but after - // this deferred query resolves, the UI will update. - const variants = context.storefront - .query(VARIANTS_QUERY, { - variables: {handle: params.handle!}, - }) - .catch((error) => { - // Log query errors, but don't throw them so the page can still render - console.error(error); - return null; - }); + return {} - return { - variants, - }; }
- Remove the redirect logic in the
loadCriticalData
function and completely removeredirectToFirstVariant
function
async function loadCriticalData({ context, params, request, }: LoaderFunctionArgs) { const {handle} = params; const {storefront} = context; if (!handle) { throw new Error('Expected product handle to be defined'); } const [{product}] = await Promise.all([ storefront.query(PRODUCT_QUERY, { variables: {handle, selectedOptions: getSelectedProductOptions(request)}, }), // Add other queries here, so that they are loaded in parallel ]); if (!product?.id) { throw new Response(null, {status: 404}); } - const firstVariant = product.variants.nodes[0]; - const firstVariantIsDefault = Boolean( - firstVariant.selectedOptions.find( - (option: SelectedOption) => - option.name === 'Title' && option.value === 'Default Title', - ), - ); - if (firstVariantIsDefault) { - product.selectedVariant = firstVariant; - } else { - // if no selected variant was returned from the selected options, - // we redirect to the first variant's url with it's selected options applied - if (!product.selectedVariant) { - throw redirectToFirstVariant({product, request}); - } - } return { product, }; } ... - function redirectToFirstVariant({ - product, - request, - }: { - product: ProductFragment; - request: Request; - }) { - ... - }
- Update the
Product
component to use the new data fields.
import { getSelectedProductOptions, Analytics, useOptimisticVariant, + getAdjacentAndFirstAvailableVariants, } from '@shopify/hydrogen'; export default function Product() { + const {product} = useLoaderData<typeof loader>(); - const {product, variants} = useLoaderData<typeof loader>(); + // Optimistically selects a variant with given available variant information + const selectedVariant = useOptimisticVariant( + product.selectedOrFirstAvailableVariant, + getAdjacentAndFirstAvailableVariants(product), + ); - const selectedVariant = useOptimisticVariant( - product.selectedVariant, - variants, - );
- Handle missing search query param in url from selecting a first variant
import { getSelectedProductOptions, Analytics, useOptimisticVariant, getAdjacentAndFirstAvailableVariants, + useSelectedOptionInUrlParam, } from '@shopify/hydrogen'; export default function Product() { const {product} = useLoaderData<typeof loader>(); // Optimistically selects a variant with given available variant information const selectedVariant = useOptimisticVariant( product.selectedOrFirstAvailableVariant, getAdjacentAndFirstAvailableVariants(product), ); + // Sets the search param to the selected variant without navigation + // only when no search params are set in the url + useSelectedOptionInUrlParam(selectedVariant.selectedOptions);
- Get the product options array using
getProductOptions
import { getSelectedProductOptions, Analytics, useOptimisticVariant, + getProductOptions, getAdjacentAndFirstAvailableVariants, useSelectedOptionInUrlParam, } from '@shopify/hydrogen'; export default function Product() { const {product} = useLoaderData<typeof loader>(); // Optimistically selects a variant with given available variant information const selectedVariant = useOptimisticVariant( product.selectedOrFirstAvailableVariant, getAdjacentAndFirstAvailableVariants(product), ); // Sets the search param to the selected variant without navigation // only when no search params are set in the url useSelectedOptionInUrlParam(selectedVariant.selectedOptions); + // Get the product options array + const productOptions = getProductOptions({ + ...product, + selectedOrFirstAvailableVariant: selectedVariant, + });
- Remove the
Await
andSuspense
from theProductForm
. We no longer have any queries that we need to wait for.
export default function Product() { ... return ( ... + <ProductForm + productOptions={productOptions} + selectedVariant={selectedVariant} + /> - <Suspense - fallback={ - <ProductForm - product={product} - selectedVariant={selectedVariant} - variants={[]} - /> - } - > - <Await - errorElement="There was a problem loading product variants" - resolve={variants} - > - {(data) => ( - <ProductForm - product={product} - selectedVariant={selectedVariant} - variants={data?.product?.variants.nodes || []} - /> - )} - </Await> - </Suspense>
- Update the
ProductForm
component.
import {Link, useNavigate} from '@remix-run/react'; import {type MappedProductOptions} from '@shopify/hydrogen'; import type { Maybe, ProductOptionValueSwatch, } from '@shopify/hydrogen/storefront-api-types'; import {AddToCartButton} from './AddToCartButton'; import {useAside} from './Aside'; import type {ProductFragment} from 'storefrontapi.generated'; export function ProductForm({ productOptions, selectedVariant, }: { productOptions: MappedProductOptions[]; selectedVariant: ProductFragment['selectedOrFirstAvailableVariant']; }) { const navigate = useNavigate(); const {open} = useAside(); return ( <div className="product-form"> {productOptions.map((option) => ( <div className="product-options" key={option.name}> <h5>{option.name}</h5> <div className="product-options-grid"> {option.optionValues.map((value) => { const { name, handle, variantUriQuery, selected, available, exists, isDifferentProduct, swatch, } = value; if (isDifferentProduct) { // SEO // When the variant is a combined listing child product // that leads to a different url, we need to render it // as an anchor tag return ( <Link className="product-options-item" key={option.name + name} prefetch="intent" preventScrollReset replace to={`/products/${handle}?${variantUriQuery}`} style={{ border: selected ? '1px solid black' : '1px solid transparent', opacity: available ? 1 : 0.3, }} > <ProductOptionSwatch swatch={swatch} name={name} /> </Link> ); } else { // SEO // When the variant is an update to the search param, // render it as a button with javascript navigating to // the variant so that SEO bots do not index these as // duplicated links return ( <button type="button" className={`product-options-item${ exists && !selected ? ' link' : '' }`} key={option.name + name} style={{ border: selected ? '1px solid black' : '1px solid transparent', opacity: available ? 1 : 0.3, }} disabled={!exists} onClick={() => { if (!selected) { navigate(`?${variantUriQuery}`, { replace: true, }); } }} > <ProductOptionSwatch swatch={swatch} name={name} /> </button> ); } })} </div> <br /> </div> ))} <AddToCartButton disabled={!selectedVariant || !selectedVariant.availableForSale} onClick={() => { open('cart'); }} lines={ selectedVariant ? [ { merchandiseId: selectedVariant.id, quantity: 1, selectedVariant, }, ] : [] } > {selectedVariant?.availableForSale ? 'Add to cart' : 'Sold out'} </AddToCartButton> </div> ); } function ProductOptionSwatch({ swatch, name, }: { swatch?: Maybe<ProductOptionValueSwatch> | undefined; name: string; }) { const image = swatch?.image?.previewImage?.url; const color = swatch?.color; if (!image && !color) return name; return ( <div aria-label={name} className="product-option-label-swatch" style={{ backgroundColor: color || 'transparent', }} > {!!image && <img src={image} alt={name} />} </div> ); }
- Update
app.css
+ /* + * -------------------------------------------------- + * Non anchor links + * -------------------------------------------------- + */ + .link:hover { + text-decoration: underline; + cursor: pointer; + } ... - .product-options-item { + .product-options-item, + .product-options-item:disabled { + padding: 0.25rem 0.5rem; + background-color: transparent; + font-size: 1rem; + font-family: inherit; + } + .product-option-label-swatch { + width: 1.25rem; + height: 1.25rem; + margin: 0.25rem 0; + } + .product-option-label-swatch img { + width: 100%; + }
- Update
lib/variants.ts
Make
useVariantUrl
andgetVariantUrl
flexible to supplying a selected option paramexport function useVariantUrl( handle: string, - selectedOptions: SelectedOption[], + selectedOptions?: SelectedOption[], ) { const {pathname} = useLocation(); return useMemo(() => { return getVariantUrl({ handle, pathname, searchParams: new URLSearchParams(), selectedOptions, }); }, [handle, selectedOptions, pathname]); } export function getVariantUrl({ handle, pathname, searchParams, selectedOptions, }: { handle: string; pathname: string; searchParams: URLSearchParams; - selectedOptions: SelectedOption[]; + selectedOptions?: SelectedOption[], }) { const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname); const isLocalePathname = match && match.length > 0; const path = isLocalePathname ? `${match![0]}products/${handle}` : `/products/${handle}`; - selectedOptions.forEach((option) => { + selectedOptions?.forEach((option) => { searchParams.set(option.name, option.value); });
- Update
routes/collections.$handle.tsx
We no longer need to query for the variants since product route can efficiently
obtain the first available variants. Update the code to reflect that:const PRODUCT_ITEM_FRAGMENT = `#graphql fragment MoneyProductItem on MoneyV2 { amount currencyCode } fragment ProductItem on Product { id handle title featuredImage { id altText url width height } priceRange { minVariantPrice { ...MoneyProductItem } maxVariantPrice { ...MoneyProductItem } } - variants(first: 1) { - nodes { - selectedOptions { - name - value - } - } - } } ` as const;
and remove the variant reference
function ProductItem({ product, loading, }: { product: ProductItemFragment; loading?: 'eager' | 'lazy'; }) { - const variant = product.variants.nodes[0]; - const variantUrl = useVariantUrl(product.handle, variant.selectedOptions); + const variantUrl = useVariantUrl(product.handle); return (
- Update
routes/collections.all.tsx
Same reasoning as
collections.$handle.tsx
const PRODUCT_ITEM_FRAGMENT = `#graphql fragment MoneyProductItem on MoneyV2 { amount currencyCode } fragment ProductItem on Product { id handle title featuredImage { id altText url width height } priceRange { minVariantPrice { ...MoneyProductItem } maxVariantPrice { ...MoneyProductItem } } - variants(first: 1) { - nodes { - selectedOptions { - name - value - } - } - } } ` as const;
and remove the variant reference
function ProductItem({ product, loading, }: { product: ProductItemFragment; loading?: 'eager' | 'lazy'; }) { - const variant = product.variants.nodes[0]; - const variantUrl = useVariantUrl(product.handle, variant.selectedOptions); + const variantUrl = useVariantUrl(product.handle); return (
- Update
routes/search.tsx
Instead of using the first variant, use
selectedOrFirstAvailableVariant
const SEARCH_PRODUCT_FRAGMENT = `#graphql fragment SearchProduct on Product { __typename handle id publishedAt title trackingParameters vendor - variants(first: 1) { - nodes { + selectedOrFirstAvailableVariant( + selectedOptions: [] + ignoreUnknownOptions: true + caseInsensitiveMatch: true + ) { id image { url altText width height } price { amount currencyCode } compareAtPrice { amount currencyCode } selectedOptions { name value } product { handle title } } - } } ` as const;
const PREDICTIVE_SEARCH_PRODUCT_FRAGMENT = `#graphql fragment PredictiveProduct on Product { __typename id title handle trackingParameters - variants(first: 1) { - nodes { + selectedOrFirstAvailableVariant( + selectedOptions: [] + ignoreUnknownOptions: true + caseInsensitiveMatch: true + ) { id image { url altText width height } price { amount currencyCode } } - } }
- Update
components/SearchResults.tsx
function SearchResultsProducts({ term, products, }: PartialSearchResult<'products'>) { if (!products?.nodes.length) { return null; } return ( <div className="search-result"> <h2>Products</h2> <Pagination connection={products}> {({nodes, isLoading, NextLink, PreviousLink}) => { const ItemsMarkup = nodes.map((product) => { const productUrl = urlWithTrackingParams({ baseUrl: `/products/${product.handle}`, trackingParams: product.trackingParameters, term, }); + const price = product?.selectedOrFirstAvailableVariant?.price; + const image = product?.selectedOrFirstAvailableVariant?.image; return ( <div className="search-results-item" key={product.id}> <Link prefetch="intent" to={productUrl}> - {product.variants.nodes[0].image && ( + {image && ( <Image - data={product.variants.nodes[0].image} + data={image} alt={product.title} width={50} /> )} <div> <p>{product.title}</p> <small> - <Money data={product.variants.nodes[0].price} /> + {price && + <Money data={price} /> + } </small> </div> </Link> </div> ); });
- Update
components/SearchResultsPredictive.tsx
function SearchResultsPredictiveProducts({ term, products, closeSearch, }: PartialPredictiveSearchResult<'products'>) { if (!products.length) return null; return ( <div className="predictive-search-result" key="products"> <h5>Products</h5> <ul> {products.map((product) => { const productUrl = urlWithTrackingParams({ baseUrl: `/products/${product.handle}`, trackingParams: product.trackingParameters, term: term.current, }); + const price = product?.selectedOrFirstAvailableVariant?.price; - const image = product?.variants?.nodes?.[0].image; + const image = product?.selectedOrFirstAvailableVariant?.image; return ( <li className="predictive-search-result-item" key={product.id}> <Link to={productUrl} onClick={closeSearch}> {image && ( <Image alt={image.altText ?? ''} src={image.url} width={50} height={50} /> )} <div> <p>{product.title}</p> <small> - {product?.variants?.nodes?.[0].price && ( + {price && ( - <Money data={product.variants.nodes[0].price} /> + <Money data={price} /> )} </small> </div> </Link> </li> ); })} </ul> </div> ); }
-
Update
Aside
to have an accessible close button label (#2639) by @lb- -
Fix cart route so that it works with no-js (#2665) by @wizardlyhel
-
Bump Shopify cli version (#2667) by @wizardlyhel
-
Updated dependencies [
8f64915e
,a57d5267
,91d60fd2
,23a80f3e
]:- @Shopify/hydrogen@2024.10.1