Skip to content

skeleton@2024.10.2

Compare
Choose a tag to compare
@shopify-github-actions-access shopify-github-actions-access released this 11 Dec 17:13
· 5 commits to main since this release
c915b67

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

    1. 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;
    1. Update loadDeferredData function. We no longer need to load in all the variants. You can also remove VARIANTS_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,
    -  };
    }
    1. Remove the redirect logic in the loadCriticalData function and completely remove redirectToFirstVariant 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;
    -  }) {
    -    ...
    -  }
    1. 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,
    -  );
    1. 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);
    1. 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,
    +  });
    1. Remove the Await and Suspense from the ProductForm. 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>
    1. 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>
      );
    }
    1. 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%;
    +  }
    1. Update lib/variants.ts

    Make useVariantUrl and getVariantUrl flexible to supplying a selected option param

    export 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);
      });
    1. 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 (
    1. 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 (
    1. 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
            }
         }
    -    }
      }
    1. 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>
                );
              });
    1. 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]: