SSR Cart Migration

This guide outlines the steps to migrate a Pack Hydrogen storefront using client-side cart with server-side cart. This will include:

  • New ($locale).api.cart.tsx route
  • New CartProvider and PreviewModeCartProvider context providers
  • New useCart hook (replaced useCart from @shopify/hydrogen-react)
  • Update to useAddToCart and useCartLine hooks
  • Update to Graphql fragments
  • Update cart analytics logic
  • Update to ($locale).cart.$lines.tsx and ($locale).discounts.$code.tsx
  • New cart typescript types
  • Necessary tweak to CartUpsell.tsx (if applicable)

Things to Consider

At the bottom under LLM Prompt is a single prompt that can be passed into an LLM for automating each task. These prompts have been tested internally using the IDE Cursor or the Claude Code extension for VS Code

Adjust the chat settings accordingly to allow agent to write files without asking for permission each time:

If using Cursor:

  1. Turn on Agent mode
  2. Turn on Auto-Run Mode

If using Claude Code extension for VS Code:

  1. Type in / into chat to open menu, then click on General config...
  2. Enable Claude Code: Allow Dangerously Skip Permissions

Manual Setup

Cart route

  1. Add ($locale).api.cart.tsx to app/routes
import {CartForm} from '@shopify/hydrogen';
import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from '@shopify/remix-oxygen';
import type {CartQueryDataReturn} from '@shopify/hydrogen';
import type {Cart} from '@shopify/hydrogen/storefront-api-types';

import {isLocalPath} from '~/lib/utils';

const getParsedJson = (value: FormDataEntryValue | null) => {
  if (!value || typeof value !== 'string') return value;
  try {
    return JSON.parse(value);
  } catch (error) {
    return value;
  }
};

export async function action({request, context}: ActionFunctionArgs) {
  const {cart} = context;

  const formData = await request.formData();
  const action = formData.get('action');

  let status = 200;
  let result: CartQueryDataReturn;

  if (!action)
    return Response.json({errors: ['Missing `action` in body']}, {status: 400});

  switch (action) {
    case CartForm.ACTIONS.Create:
      result = await cart.create(getParsedJson(formData.get('cart')));
      break;
    case CartForm.ACTIONS.LinesAdd:
      result = await cart.addLines(getParsedJson(formData.get('lines')));
      break;
    case CartForm.ACTIONS.LinesUpdate:
      result = await cart.updateLines(getParsedJson(formData.get('lines')));
      break;
    case CartForm.ACTIONS.LinesRemove:
      result = await cart.removeLines(getParsedJson(formData.get('lineIds')));
      break;
    case CartForm.ACTIONS.DiscountCodesUpdate:
      const formDiscountCodes = getParsedJson(formData.get('discountCodes'));
      const discountCodes = (
        Array.isArray(formDiscountCodes) ? formDiscountCodes : []
      ) as string[];
      // Combine discount codes already applied on cart
      const existingCartWithDiscountCodes = (await cart.get()) as Cart;
      if (existingCartWithDiscountCodes) {
        discountCodes.push(
          ...(existingCartWithDiscountCodes.discountCodes?.map(
            ({code}) => code,
          ) || []),
        );
      }
      result = await cart.updateDiscountCodes(discountCodes);
      break;
    case CartForm.ACTIONS.BuyerIdentityUpdate:
      result = await cart.updateBuyerIdentity(
        getParsedJson(formData.get('buyerIdentity')),
      );
      break;
    case CartForm.ACTIONS.AttributesUpdateInput:
      const formAttributes = getParsedJson(formData.get('attributes'));
      const attributes = Array.isArray(formAttributes) ? formAttributes : [];
      // Combine cart attributes in cart
      const existingCartWithAttributes = (await cart.get()) as Cart;
      if (existingCartWithAttributes) {
        attributes.push(...(existingCartWithAttributes.attributes || []));
      }
      result = await cart.updateAttributes(attributes);
      break;
    case CartForm.ACTIONS.NoteUpdate:
      result = await cart.updateNote(formData.get('note') as string);
      break;
    default:
      return Response.json(
        {errors: [`${action} cart action is not defined`]},
        {status: 400},
      );
  }

  /**
   * The Cart ID may change after each mutation. We need to update it each time in the session.
   */
  let headers = new Headers();
  if (result.cart?.id) headers = cart.setCartId(result.cart.id);

  const redirectTo = formData.get('redirectTo') ?? null;
  if (typeof redirectTo === 'string' && isLocalPath(redirectTo)) {
    status = 303;
    headers.set('Location', redirectTo);
  }

  const {cart: cartResult, warnings, userErrors} = result;

  return Response.json(
    {
      cart: cartResult,
      userErrors,
      warnings,
    },
    {status, headers},
  );
}

export async function loader({context}: LoaderFunctionArgs) {
  return Response.json({cart: await context.cart.get()});
}
  1. Add isLocalPath to app/lib/utils/document.utils.ts:
/**
 * Validates that a url is local
 * @param url
 * @returns `true` if local `false`if external domain
 */
export function isLocalPath(url: string) {
  try {
    // We don't want to redirect cross domain,
    // doing so could create fishing vulnerability
    // If `new URL()` succeeds, it's a fully qualified
    // url which is cross domain. If it fails, it's just
    // a path, which will be the current domain.
    new URL(url);
  } catch (e) {
    return true;
  }

  return false;
}

Providers

  1. In root.tsx loader:
  • Destructure out cart from context
  • Return cart.get() as cart from the loader's return, e.g. return {analytics, cart: cart.get(), ... }
  1. In the app/contexts folder, create folder CartProvider
  2. Add following files:

CartProvider.tsx

import {useEffect, useMemo, useReducer} from 'react';
import type {ReactNode} from 'react';
import type {Cart} from '@shopify/hydrogen/storefront-api-types';

import type {Action, CartState, Dispatch} from '~/lib/types';
import {useRootLoaderData} from '~/hooks';

import {Context} from './useCartContext';

const cartState = {
  cart: null,
  status: 'uninitialized',
  error: null,
};

const reducer = (state: CartState, action: Action) => {
  switch (action.type) {
    case 'SET_CART':
      return {
        ...state,
        cart: action.payload,
      };
    case 'SET_STATUS':
      return {
        ...state,
        status: action.payload,
      };
    case 'SET_ERROR':
      return {
        ...state,
        error: action.payload,
      };
    default:
      throw new Error(`Invalid Context action of type: ${action.type}`);
  }
};

const actions = (dispatch: Dispatch) => ({
  setCart: (cart: Cart) => dispatch({type: 'SET_CART', payload: cart}),
  setStatus: (status: CartState['status']) =>
    dispatch({type: 'SET_STATUS', payload: status}),
  setError: (error: unknown) => dispatch({type: 'SET_ERROR', payload: error}),
});

export function CartProvider({children}: {children: ReactNode}) {
  const {cart} = useRootLoaderData();

  const [state, dispatch] = useReducer(reducer, cartState);

  const value = useMemo(() => ({state, actions: actions(dispatch)}), [state]);

  /*
   * Fetch cart on initial load and set in context
   */
  useEffect(() => {
    if (value.state.status !== 'uninitialized') return;
    const setCart = async () => {
      value.actions.setError(null);
      const cartData = await (cart as Promise<Cart>);
      value.actions.setCart(cartData);
      if (cartData) value.actions.setStatus('idle');
    };
    setCart();
  }, [cart, value.state.status === 'uninitialized']);

  return <Context.Provider value={value}>{children}</Context.Provider>;
}

useCartContext.ts

import {createContext, useContext} from 'react';

import type {CartContext} from '~/lib/types';

export const Context = createContext({
  state: {},
  actions: {},
} as CartContext);

export const useCartContext = () => useContext(Context);
  1. In the app/contexts folder, add the file PreviewModeCartProvider.tsx
import {useMemo} from 'react';
import {CartProvider, ShopifyProvider} from '@shopify/hydrogen-react';
import type {ReactNode} from 'react';

import {CART_FRAGMENT} from '~/data/graphql/storefront/cart';
import {useLocale, useRootLoaderData} from '~/hooks';
import {DEFAULT_STOREFRONT_API_VERSION} from '~/lib/constants';

export function PreviewModeCartProvider({children}: {children: ReactNode}) {
  const {ENV, isPreviewModeEnabled} = useRootLoaderData();
  const locale = useLocale();

  const clientSideCartFragment = useMemo(() => {
    return CART_FRAGMENT.replace('CartApiQuery', 'CartFragment');
  }, [CART_FRAGMENT]);

  if (isPreviewModeEnabled) {
    return (
      <ShopifyProvider
        storeDomain={`https://${ENV.PUBLIC_STORE_DOMAIN}`}
        storefrontToken={ENV.PUBLIC_STOREFRONT_API_TOKEN}
        storefrontApiVersion={DEFAULT_STOREFRONT_API_VERSION}
        countryIsoCode={locale.country}
        languageIsoCode={locale.language}
      >
        <CartProvider cartFragment={clientSideCartFragment}>
          {children}
        </CartProvider>
      </ShopifyProvider>
    );
  }

  return children;
}

PreviewModeCartProvider.displayName = 'PreviewModeCartProvider';
  1. Find wherever <<ShopifyProvider><CartProvider> */ ... /* </CartProvider></<ShopifyProvider> is rendered, usually Document.tsx
  • Remove those two providers
  • Add the two new cart providers as the outer most providers in ContextsProvider.tsx if it exists, otherwise add them to the same place ShopifyProvider and original CartProvider were placed:
<PreviewModeCartProvider>
  <CartProvider>*/ Other providers, code, etc /*</CartProvider>
</PreviewModeCartProvider>
  • Import PreviewModeCartProvider and CartProvider accordingly

useCart hook

  1. Add useCart.ts to app/hooks/cart
import {useCallback} from 'react';
import {CartForm, flattenConnection, useAnalytics} from '@shopify/hydrogen';
import {useCart as useClientSideCart} from '@shopify/hydrogen-react';
import equal from 'fast-deep-equal';
import type {
  Cart,
  CartBuyerIdentityInput,
  CartLine,
  CartLineInput,
} from '@shopify/hydrogen/storefront-api-types';

import {useCartContext} from '~/contexts/CartProvider/useCartContext';
import {AnalyticsEvent} from '~/components/Analytics/constants';
import type {CartActionData, CartWithActions} from '~/lib/types';

import {useRootLoaderData} from '../useRootLoaderData';

export const useCart = (): CartWithActions => {
  const {isPreviewModeEnabled} = useRootLoaderData();
  const {publish, shop} = useAnalytics();

  const {
    state: {cart, status, error},
    actions: {setCart, setError, setStatus},
  } = useCartContext();

  const getCartActionData = useCallback(
    async (formData: FormData): Promise<CartActionData | null> => {
      const action = formData.get('action');
      let data = null as CartActionData | null;
      setError(null);
      setStatus(action === CartForm.ACTIONS.Create ? 'creating' : 'updating');
      const response = await fetch('/api/cart', {
        method: 'POST',
        body: formData,
      });
      data = await response.json();
      if (data?.userErrors?.length) {
        setError(data?.userErrors);
      }
      if (data?.cart) {
        setCart(data.cart);
      }
      setStatus('idle');
      return data;
    },
    [],
  );

  const cartCreate: CartWithActions['cartCreate'] = useCallback(
    async (cartInput) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.Create);
      formData.append('cart', JSON.stringify(cartInput));
      return getCartActionData(formData);
    },
    [],
  );

  const linesAdd: CartWithActions['linesAdd'] = useCallback(
    async (lines) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.LinesAdd);
      formData.append('lines', JSON.stringify(lines));
      const data = await getCartActionData(formData);
      if (data?.cart) {
        lines.forEach((lineInput) => {
          const prevLine =
            flattenConnection(cart?.lines).find((prevCartLine) => {
              return compareInputWithLine(lineInput, prevCartLine as CartLine);
            }) || null;
          let currentLine =
            flattenConnection(data.cart?.lines).find((currentCartLine) => {
              return compareInputWithLine(
                lineInput,
                currentCartLine as CartLine,
              );
            }) || null;
            // If current line cannot be matched through comparing attributes, fallback to ignoring attributes and just find matching merchandise id for sake of analytics event
          if (!currentLine) {
            currentLine =
              flattenConnection(data.cart?.lines).find((currentCartLine) => {
                return (
                  currentCartLine.merchandise.id === lineInput.merchandiseId &&
                  lineInput.sellingPlanId ===
                    currentCartLine.sellingPlanAllocation?.sellingPlan?.id
                );
              }) || null;
          }
          publish(AnalyticsEvent.PRODUCT_ADD_TO_CART, {
            cart: data.cart,
            prevCart: cart,
            currentLine,
            prevLine,
            shop,
          });
        });
      }
      return data;
    },
    [cart, publish, shop],
  );

  const linesUpdate: CartWithActions['linesUpdate'] = useCallback(
    async (lines) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.LinesUpdate);
      formData.append('lines', JSON.stringify(lines));
      const data = await getCartActionData(formData);
      if (data?.cart) {
        lines.forEach((lineInput) => {
          const prevLine =
            flattenConnection(cart?.lines).find((prevCartLine) => {
              return prevCartLine.id === lineInput.id;
            }) || null;
          const currentLine =
            flattenConnection(data.cart?.lines).find((currentCartLine) => {
              return currentCartLine.id === lineInput.id;
            }) || null;
          if ((currentLine?.quantity || 0) > (prevLine?.quantity || 0)) {
            publish(AnalyticsEvent.PRODUCT_ADD_TO_CART, {
              cart: data.cart,
              prevCart: cart,
              currentLine,
              prevLine,
              shop,
            });
          } else if ((currentLine?.quantity || 0) < (prevLine?.quantity || 0)) {
            publish(AnalyticsEvent.PRODUCT_REMOVED_FROM_CART, {
              cart: data.cart,
              prevCart: cart,
              currentLine,
              prevLine,
              shop,
            });
          }
        });
      }
      return data;
    },
    [cart, publish, shop],
  );

  const linesRemove: CartWithActions['linesRemove'] = useCallback(
    async (lineIds) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.LinesRemove);
      formData.append('lineIds', JSON.stringify(lineIds));
      const data = await getCartActionData(formData);
      if (data?.cart) {
        lineIds.forEach((lineId) => {
          const prevLine =
            flattenConnection(cart?.lines).find((prevCartLine) => {
              return prevCartLine.id === lineId;
            }) || null;
          const currentLine =
            flattenConnection(data.cart?.lines).find((currentCartLine) => {
              return currentCartLine.id === lineId;
            }) || null;
          publish(AnalyticsEvent.PRODUCT_REMOVED_FROM_CART, {
            cart: data.cart,
            prevCart: cart,
            currentLine,
            prevLine,
            shop,
          });
        });
      }
      return data;
    },
    [cart, publish, shop],
  );

  const discountCodesUpdate: CartWithActions['discountCodesUpdate'] =
    useCallback(async (discountCodes) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.DiscountCodesUpdate);
      formData.append('discountCodes', JSON.stringify(discountCodes));
      return getCartActionData(formData);
    }, []);

  const cartAttributesUpdate: CartWithActions['cartAttributesUpdate'] =
    useCallback(async (attributes) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.AttributesUpdateInput);
      formData.append('attributes', JSON.stringify(attributes));
      return getCartActionData(formData);
    }, []);

  const buyerIdentityUpdate: CartWithActions['buyerIdentityUpdate'] =
    useCallback(
      async (
        buyerIdentity: CartBuyerIdentityInput,
      ): Promise<CartActionData | null> => {
        const formData = new FormData();
        formData.append('action', CartForm.ACTIONS.BuyerIdentityUpdate);
        formData.append('buyerIdentity', JSON.stringify(buyerIdentity));
        return getCartActionData(formData);
      },
      [],
    );

  const noteUpdate: CartWithActions['noteUpdate'] = useCallback(
    async (note) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.NoteUpdate);
      formData.append('note', note);
      return getCartActionData(formData);
    },
    [],
  );

  return (
    isPreviewModeEnabled
      ? // eslint-disable-next-line react-hooks/rules-of-hooks
        useClientSideCart()
      : {
          ...cart,
          ...(cart?.lines ? {lines: flattenConnection(cart.lines)} : null),
          buyerIdentityUpdate,
          cartAttributesUpdate,
          cartCreate,
          discountCodesUpdate,
          error,
          linesAdd,
          linesRemove,
          linesUpdate,
          noteUpdate,
          status,
        }
  ) as CartWithActions;
};

const compareInputWithLine = (input: CartLineInput, line: CartLine) => {
  const inputAttrLength = input.attributes ? input.attributes.length : 0;
  const lineAttrLength = line.attributes ? line.attributes.length : 0;
  if (inputAttrLength !== lineAttrLength) return false;
  if (inputAttrLength || lineAttrLength) {
    const inputAttributesByKey = input.attributes?.reduce(
      (acc: Record<string, string>, attr) => {
        acc[attr.key] = attr.value;
        return acc;
      },
      {},
    );
    const lineAttributesByKey = line.attributes?.reduce(
      (acc: Record<string, string>, attr) => {
        acc[attr.key] = attr.value;
        return acc;
      },
      {},
    );
    if (!equal(inputAttributesByKey, lineAttributesByKey)) return false;
  }
  if (input.sellingPlanId !== line.sellingPlanAllocation?.sellingPlan?.id)
    return false;
  return line.merchandise.id === input.merchandiseId;
};
  1. Some legacy stores will instead use PackEventName imported from app/components/PackAnalytics/constants.ts instead of AnalyticsEvent. If this is the case, change the import to import {PackEventName} from '~/components/PackAnalytics/constants'; and update all instances of AnalyticsEvent with PackEventName
  2. Export useCart from the /cart index.ts file

useAddToCart hook

  1. Update useAddToCart.tsx to reflect:
import {useCallback, useEffect, useState} from 'react';
import type {
  AttributeInput,
  ProductVariant,
  SellingPlan,
} from '@shopify/hydrogen/storefront-api-types';

import {useCart, useMenu, useRootLoaderData, useSettings} from '~/hooks';

/**
 * Add to cart hook
 * @param addToCartTextOverride - Add to cart button text override
 * @param attributes - Array of attributes
 * @param quantity - Quantity
 * @param selectedVariant - Selected variant
 * @param sellingPlanId - Selling plan id
 * @returns Add to cart hook return
 * @example
 * ```tsx
 * const {buttonText, cartIsUpdating, handleAddToCart, isAdded, isAdding, isSoldOut, subtext} = useAddToCart({
 *   addToCartText: 'Add to cart!',
 *   attributes: [{name: 'Color', value: 'Red'}],
 *   quantity: 1,
 *   selectedVariant: product.variants[0],
 * });
 * ```
 */

interface UseAddToCartProps {
  addToCartText?: string;
  attributes?: AttributeInput[];
  quantity?: number;
  selectedVariant?: ProductVariant | null;
  sellingPlanId?: SellingPlan['id'];
}

interface UseAddToCartReturn {
  buttonText: string;
  cartIsUpdating: boolean;
  failed: boolean;
  handleAddToCart: () => void;
  handleNotifyMe: (component: React.ReactNode) => void;
  isAdded: boolean;
  isAdding: boolean;
  isNotifyMe: boolean;
  isSoldOut: boolean;
  subtext: string;
}

export function useAddToCart({
  addToCartText: addToCartTextOverride = '',
  attributes,
  quantity = 1,
  selectedVariant = null,
  sellingPlanId,
}: UseAddToCartProps): UseAddToCartReturn {
  const {error, linesAdd, status} = useCart();
  const {isPreviewModeEnabled} = useRootLoaderData();
  const {product: productSettings} = useSettings();
  const {openCart, openModal} = useMenu();

  const [isAdding, setIsAdding] = useState(false);
  const [isAdded, setIsAdded] = useState(false);
  const [failed, setFailed] = useState(false);

  const enabledNotifyMe = productSettings?.backInStock?.enabled ?? true;
  const variantIsSoldOut = selectedVariant && !selectedVariant.availableForSale;
  const variantIsPreorder = !!selectedVariant?.currentlyNotInStock;

  let buttonText = '';
  if (failed) {
    buttonText = 'Failed To Add';
  } else if (variantIsPreorder) {
    buttonText = productSettings?.addToCart?.preorderText || 'Preorder';
  } else if (variantIsSoldOut) {
    buttonText = enabledNotifyMe
      ? productSettings?.backInStock?.notifyMeText || 'Notify Me'
      : productSettings?.addToCart?.soldOutText || 'Sold Out';
  } else {
    buttonText =
      addToCartTextOverride ||
      productSettings?.addToCart?.addToCartText ||
      'Add To Cart';
  }

  const cartIsUpdating = status === 'creating' || status === 'updating';

  const handleAddToCart = useCallback(async () => {
    if (!selectedVariant?.id || isAdding || cartIsUpdating) return;
    setIsAdding(true);
    setFailed(false);
    const data = await linesAdd([
      {
        attributes,
        merchandiseId: selectedVariant.id,
        quantity,
        sellingPlanId,
      },
    ]);
    if (data) {
      if (data.userErrors?.length) {
        setIsAdding(false);
        setFailed(true);
        setTimeout(() => setFailed(false), 3000);
      } else {
        setIsAdding(false);
        setIsAdded(true);
        openCart();
        setTimeout(() => setIsAdded(false), 1000);
      }
    }
  }, [
    attributes,
    isAdding,
    linesAdd,
    quantity,
    selectedVariant?.id,
    sellingPlanId,
    status,
  ]);

  const handleNotifyMe = useCallback(
    (component: React.ReactNode) => {
      if (!selectedVariant?.id) return;
      openModal(component);
    },
    [selectedVariant?.id],
  );

  useEffect(() => {
    if (!isPreviewModeEnabled) return;
    if (isAdding && status === 'idle') {
      setIsAdding(false);
      setIsAdded(true);
      openCart();
      setTimeout(() => setIsAdded(false), 1000);
    }
  }, [status, isAdding, isPreviewModeEnabled]);

  useEffect(() => {
    if (!error) return;
    if (Array.isArray(error)) {
      error.forEach((e) => {
        console.error('useCart:error', e.message);
      });
    } else {
      console.error('useCart:error', error);
    }
  }, [error]);

  return {
    buttonText,
    cartIsUpdating, // cart is updating
    failed,
    handleAddToCart,
    handleNotifyMe,
    isAdded, // line is added (true for only a second)
    isAdding, // line is adding
    isNotifyMe: !!variantIsSoldOut && enabledNotifyMe,
    isSoldOut: !!variantIsSoldOut,
    subtext: productSettings?.addToCart?.subtext,
  };
}
  1. Ensure that no third party or supplemental logic was removed uninintentionally. If so incorporate back in accordingly

useCartLine Hook

  1. Update useCartLine.ts to be:
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import debounce from 'lodash/debounce';
import type {CartLine} from '@shopify/hydrogen/storefront-api-types';

import {useCart} from '~/hooks';

export const useCartLine = ({line}: {line: CartLine}) => {
  const {id, quantity} = {...line};
  const {linesRemove, linesUpdate, status} = useCart();

  const [optimisticQuantity, setOptimisticQuantity] = useState(quantity);

  /*
   * Optimistic quantity debounce logic
   */
  const handleDebouncedDecrement = useCallback(async () => {
    if (optimisticQuantity > 0) {
      const data = await linesUpdate([{id, quantity: optimisticQuantity}]);
      if (data && !data.cart) setOptimisticQuantity(quantity);
    } else {
      const data = await linesRemove([id]);
      if (data && !data.cart) setOptimisticQuantity(quantity);
    }
  }, [id, linesRemove, linesUpdate, optimisticQuantity, quantity, status]);

  const handleDebouncedIncrement = useCallback(async () => {
    const data = await linesUpdate([{id, quantity: optimisticQuantity}]);
    if (data && !data.cart) setOptimisticQuantity(quantity);
  }, [id, linesUpdate, optimisticQuantity, quantity, status]);

  const debouncedDecrementRef = useRef(handleDebouncedDecrement);
  const debouncedIncrementRef = useRef(handleDebouncedIncrement);

  useEffect(() => {
    debouncedDecrementRef.current = handleDebouncedDecrement;
    debouncedIncrementRef.current = handleDebouncedIncrement;
  }, [handleDebouncedDecrement, handleDebouncedIncrement]);

  const doDecrementCallbackWithDebounce = useMemo(() => {
    const callback = () => debouncedDecrementRef.current();
    return debounce(callback, 200);
  }, []);

  const doIncrementCallbackWithDebounce = useMemo(() => {
    const callback = () => debouncedIncrementRef.current();
    return debounce(callback, 200);
  }, []);

  /*
   * Cart line handlers
   */
  const handleDecrement = useCallback(() => {
    if (optimisticQuantity > 0) {
      doDecrementCallbackWithDebounce();
      setOptimisticQuantity(optimisticQuantity - 1);
    } else {
      setOptimisticQuantity(0);
      linesRemove([id]);
    }
  }, [doDecrementCallbackWithDebounce, id, linesRemove, optimisticQuantity]);

  const handleIncrement = useCallback(() => {
    doIncrementCallbackWithDebounce();
    setOptimisticQuantity(optimisticQuantity + 1);
  }, [doIncrementCallbackWithDebounce, optimisticQuantity]);

  const handleRemove = useCallback(async () => {
    setOptimisticQuantity(0);
    const data = await linesRemove([id]);
    if (data && !data.cart) setOptimisticQuantity(quantity);
  }, [id, linesRemove, quantity, status]);

  return {
    handleDecrement,
    handleIncrement,
    handleRemove,
    optimisticQuantity,
  };
};
  1. Update CartLine.tsx or wherever useCartLine() is used accordingly:
  • Destructure optimisticQuantity from useCart() and pass as the quantity attribute to <QuantitySelector />; remove quantity destructure from line if it's no longer used anywhere else
  • Remove isUpdatingLine from useCart() destructured object and remove isUpdating attribute from <QuantitySelector />
  • <QuantitySelector /> should look something like:
<QuantitySelector
  handleDecrement={handleDecrement}
  handleIncrement={handleIncrement}
  productTitle={merchandise.product.title}
  quantity={optimisticQuantity}
/>
  • Wrap the entire CartLine return with a optimisticQuantity > 0 conditional, e.g.
return optimisticQuantity > 0 ? <div> {/* Cart line  */} </div> : null;

Graphql Update

  1. Find where the Graphql fragment CART_FRAGMENT is defined (likely app/data/graphql/storefront/cart)
  2. Replace fragment CartFragment on Cart with fragment CartApiQuery on Cart
  3. In server.ts, update or add the following arguments to createCartHandler:
setCartId: cartSetIdDefault({domain: getCookieDomain(request.url)}),
cartQueryFragment: CART_FRAGMENT,
cartMutateFragment: CART_FRAGMENT.replace(
  'CartApiQuery',
  'CartApiMutation',
).replace('$numCartLines', '250'),
  1. First if getCookieDomain is exported from app/lib/server-utils/app.server.ts, import it from there. Otherwise if getCookieDomain is not already exported from ~/lib/utils add it to document.utils.ts:
export const getCookieDomain = (url: string) => {
  try {
    const {hostname} = new URL(url);
    const domainParts = hostname.split('.');
    return `.${
      domainParts?.length > 2 ? domainParts.slice(-2).join('.') : hostname
    }`;
  } catch (error) {
    console.error(`getCookieDomain:error:`, error);
    return '';
  }
};

/**
 * Validates that a url is local
 * @param url
 * @returns `true` if local `false`if external domain
 */
export function isLocalPath(url: string) {
  try {
    // We don't want to redirect cross domain,
    // doing so could create fishing vulnerability
    // If `new URL()` succeeds, it's a fully qualified
    // url which is cross domain. If it fails, it's just
    // a path, which will be the current domain.
    new URL(url);
  } catch (e) {
    return true;
  }

  return false;
}

Update useCart Imports

  1. Find every file where useCart is imported from @shopify/hydrogen-react, then remove the import
  2. Import instead useCart from the ~/hooks folder, i.e. import {useCart} from '~/hooks';. Ensure the import is combined with any existing imports from ~/hooks

Analytics

  1. In either the app/contexts provider AnalyticsProvider.tsx (if it exists), or otherwise Layout.tsx, i.e. wherever <Analytics.Provider> from @shopify/hydrogen is rendered:
  • Destructure cart from useRootLoaderData()
  • Replace the existing cart attribute in <Analytics.Provider> with cart={cart as Promise<Cart>}
  • Add import type {Cart} from '@shopify/hydrogen/storefront-api-types';
  • If const cartForAnalytics = useCartForAnalytics(); is defined, remove that; or cartForAnalytics is defined locally with a useMemo remove that, as well as any mention of cart from useCart and isCartReady from useGlobal
  • Remove unused imports
  • If useCartForAnalytics.ts does exist under app/hooks/cart, delete the file and remove its export from index.ts
  1. In either app/components/Analytics/constants.ts or app/components/PackAnalytics/constants.ts (legacy), update the const either named AnalyticsEvent or PackEventName (legacy) accordingly:
export const AnalyticsEvent = { // legacy is called `PackEventName`
  ...HydrogenAnalyticsEvent, // legacy spreads ...AnalyticsEvent
  PRODUCT_ADD_TO_CART: 'custom_product_added_to_cart',
  PRODUCT_REMOVED_FROM_CART: 'custom_product_removed_from_cart',
  ...
} as Omit<
  typeof HydrogenAnalyticsEvent,
  'PRODUCT_ADD_TO_CART' | 'PRODUCT_REMOVED_FROM_CART'
> & {
  PRODUCT_ADD_TO_CART: 'custom_product_added_to_cart';
  PRODUCT_REMOVED_FROM_CART: 'custom_product_removed_from_cart';
  ...
};

Additional Cart Routes

In app/routes add or update the following routes accordingly:

($locale).cart.$lines.tsx

import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';

/**
 * Automatically creates a new cart based on the URL and redirects straight to checkout.
 * Expected URL structure:
 * ```ts
 * /cart/<variant_id>:<quantity>
 *
 * ```
 * More than one `<variant_id>:<quantity>` separated by a comma, can be supplied in the URL, for
 * carts with more than one product variant.
 *
 * @param `?discount` an optional discount code to apply to the cart
 * @example
 * Example path creating a cart with two product variants, different quantities, and a discount code:
 * ```ts
 * /cart/41007289663544:1,41007289696312:2?discount=HYDROBOARD
 *
 * ```
 * @preserve
 */
export async function loader({request, context, params}: LoaderFunctionArgs) {
  const {cart} = context;
  const {lines} = params;
  const linesMap = lines?.split(',').map((line) => {
    const lineDetails = line.split(':');
    const variantId = lineDetails[0];
    const quantity = parseInt(lineDetails[1], 10);

    return {
      merchandiseId: `gid://shopify/ProductVariant/${variantId}`,
      quantity,
    };
  });

  const url = new URL(request.url);
  const searchParams = new URLSearchParams(url.search);

  const discount = searchParams.get('discount');
  const discountArray = discount ? [discount] : [];

  //! create a cart
  const result = await cart.create({
    lines: linesMap,
    discountCodes: discountArray,
  });

  const cartResult = result.cart;

  if (result.errors?.length || !cartResult) {
    throw new Response('Link may be expired. Try checking the URL.', {
      status: 410,
    });
  }

  // Update cart id in cookie
  const headers = cart.setCartId(cartResult.id);

  //! redirect to checkout
  if (cartResult.checkoutUrl) {
    return redirect(cartResult.checkoutUrl, {headers});
  } else {
    throw new Error('No checkout URL found');
  }
}

export default function Component() {
  return null;
}

($locale).discounts.$code.tsx

import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';

/**
 * Automatically applies a discount found on the url
 * If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied
 * @param ?redirect an optional path to return to otherwise return to the home page
 * @example
 * Example path applying a discount and redirecting
 * ```ts
 * /discount/FREESHIPPING?redirect=/products
 *
 * ```
 * @preserve
 */
export async function loader({request, context, params}: LoaderFunctionArgs) {
  const {cart} = context;
  // N.B. This route will probably be removed in the future.
  const session = context.session as any;
  const {code} = params;

  const url = new URL(request.url);
  const searchParams = new URLSearchParams(url.search);
  let redirectParam =
    searchParams.get('redirect') || searchParams.get('return_to') || '/';

  if (redirectParam.includes('//')) {
    // Avoid redirecting to external URLs to prevent phishing attacks
    redirectParam = '/';
  }

  searchParams.delete('redirect');
  searchParams.delete('return_to');

  const redirectUrl = `${redirectParam}?${searchParams}`;

  if (!code) {
    return redirect(redirectUrl);
  }

  const result = await cart.updateDiscountCodes([code]);
  const headers = cart.setCartId(result.cart.id);

  // Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000)
  // If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie
  // on localhost:3000
  return redirect(redirectUrl, {
    status: 303,
    headers,
  });
}

Cart Typescript

  1. In app/lib/types in either context.types.ts or global.types.ts, add the following cart context provider types:
export type CartStatus =
  | 'uninitialized'
  | 'creating'
  | 'fetching'
  | 'updating'
  | 'idle';

export type CartError = unknown;

export interface CartState {
  cart: Cart | null;
  status: CartStatus;
  error: CartError;
}

export interface CartActions {
  setCart: (cart: Cart) => void;
  setStatus: (status: CartState['status']) => void;
  setError: (error: unknown) => void;
}

export interface CartContext {
  state: CartState;
  actions: CartActions;
}
  1. Create a new file in in app/lib/types called cart.types.ts
import type {
  Cart,
  CartBuyerIdentityInput,
  CartInput,
  CartLine,
  CartLineInput,
  CartLineUpdateInput,
  AttributeInput,
  UserError,
  CartWarning,
} from '@shopify/hydrogen/storefront-api-types';

import type {CartError, CartStatus} from './context.types';

export interface CartActionData {
  cart: Cart | null;
  userErrors: UserError[] | null;
  warnings: CartWarning[] | null;
}

export type CartWithActions = Omit<Cart, 'lines'> & {
  lines: CartLine[];
  cartCreate: (cartInput: CartInput) => Promise<CartActionData | null>;
  linesAdd: (lines: CartLineInput[]) => Promise<CartActionData | null>;
  linesUpdate: (lines: CartLineUpdateInput[]) => Promise<CartActionData | null>;
  linesRemove: (lineIds: string[]) => Promise<CartActionData | null>;
  discountCodesUpdate: (
    discountCodes: string[],
  ) => Promise<CartActionData | null>;
  cartAttributesUpdate: (
    attributes: AttributeInput[],
  ) => Promise<CartActionData | null>;
  buyerIdentityUpdate: (
    buyerIdentity: CartBuyerIdentityInput,
  ) => Promise<CartActionData | null>;
  noteUpdate: (note: string) => Promise<CartActionData | null>;
  status: CartStatus;
  error: CartError;
};
  1. Ensure cart.types.ts gets exported in the /types index.ts file
  2. If FreeShippingMeter.tsx exists, remove the type defintion for useCart() if it exists, i.e. change = useCart() as CartWithActions & {discountAllocations: Cart['discountAllocations'];}; to just = useCart()
  3. In CartTotals.tsx
  • Remove the same type defintion for useCart() as above
  • Remove the type Cart['discountAllocations'] from parsedDiscountAllocations
  • Remove any unused imports

Misc

  1. If CartUpsell.tsx originated from the Blueprint template exists (there'll be a useEffect that has a conditional for if (status === 'idle') and has fullProductsDep as a dependency), do:
  • Remove cartLines or lines from that useEffect dependencies, and instead destructure out updatedAt from useCart() and add that to the useEffect dependencies
  • This step is important to prevent an infinite loop caused by this migration with this particular component
  1. useCartUpdateAttributes.tsx in the app/hooks/cart folder can be removed, and its export removed from the index.ts file
  • Wherever the function updateCartAttributes is used from useCartUpdateAttributes(), replace it with cartAttributesUpdate destructured from useCart()
  • Remove old import

LLM Prompts

Combined prompt

This prompt outlines the steps to migrate a Pack Hydrogen storefront using client-side cart with server-side cart. This will include:
- New `($locale).api.cart.tsx` route
- New `CartProvider` and `PreviewModeCartProvider` context providers
- New `useCart` hook
- Update to `useAddToCart` and `useCartLine` hooks
- Update to Graphql fragments
- Updates all `useCart` imports from `@shopify/hydrogen-react` with from `~/hooks`
- Update cart analytics logic
- Update to `($locale).cart.$lines.tsx` and `($locale).discounts.$code.tsx`
- New cart typescript types
- Necessary tweak to `CartUpsell.tsx` (if applicable)

After reading all these steps, separate them into their applicable groups and create checklists, then execute all the tasks.

### Cart route

1. Add `($locale).api.cart.tsx` to `app/routes`

/* Code starts below. Do not include this line */

import {CartForm} from '@shopify/hydrogen';
import type {
  ActionFunctionArgs,
  LoaderFunctionArgs,
} from '@shopify/remix-oxygen';
import type {CartQueryDataReturn} from '@shopify/hydrogen';
import type {Cart} from '@shopify/hydrogen/storefront-api-types';

import {isLocalPath} from '~/lib/utils';

const getParsedJson = (value: FormDataEntryValue | null) => {
  if (!value || typeof value !== 'string') return value;
  try {
    return JSON.parse(value);
  } catch (error) {
    return value;
  }
};

export async function action({request, context}: ActionFunctionArgs) {
  const {cart} = context;

  const formData = await request.formData();
  const action = formData.get('action');

  let status = 200;
  let result: CartQueryDataReturn;

  if (!action)
    return Response.json({errors: ['Missing `action` in body']}, {status: 400});

  switch (action) {
    case CartForm.ACTIONS.Create:
      result = await cart.create(getParsedJson(formData.get('cart')));
      break;
    case CartForm.ACTIONS.LinesAdd:
      result = await cart.addLines(getParsedJson(formData.get('lines')));
      break;
    case CartForm.ACTIONS.LinesUpdate:
      result = await cart.updateLines(getParsedJson(formData.get('lines')));
      break;
    case CartForm.ACTIONS.LinesRemove:
      result = await cart.removeLines(getParsedJson(formData.get('lineIds')));
      break;
    case CartForm.ACTIONS.DiscountCodesUpdate:
      const formDiscountCodes = getParsedJson(formData.get('discountCodes'));
      const discountCodes = (
        Array.isArray(formDiscountCodes) ? formDiscountCodes : []
      ) as string[];
      // Combine discount codes already applied on cart
      const existingCartWithDiscountCodes = (await cart.get()) as Cart;
      if (existingCartWithDiscountCodes) {
        discountCodes.push(
          ...(existingCartWithDiscountCodes.discountCodes?.map(
            ({code}) => code,
          ) || []),
        );
      }
      result = await cart.updateDiscountCodes(discountCodes);
      break;
    case CartForm.ACTIONS.BuyerIdentityUpdate:
      result = await cart.updateBuyerIdentity(
        getParsedJson(formData.get('buyerIdentity')),
      );
      break;
    case CartForm.ACTIONS.AttributesUpdateInput:
      const formAttributes = getParsedJson(formData.get('attributes'));
      const attributes = Array.isArray(formAttributes) ? formAttributes : [];
      // Combine cart attributes in cart
      const existingCartWithAttributes = (await cart.get()) as Cart;
      if (existingCartWithAttributes) {
        attributes.push(...(existingCartWithAttributes.attributes || []));
      }
      result = await cart.updateAttributes(attributes);
      break;
    case CartForm.ACTIONS.NoteUpdate:
      result = await cart.updateNote(formData.get('note') as string);
      break;
    default:
      return Response.json(
        {errors: [`${action} cart action is not defined`]},
        {status: 400},
      );
  }

  /**
   * The Cart ID may change after each mutation. We need to update it each time in the session.
   */
  let headers = new Headers();
  if (result.cart?.id) headers = cart.setCartId(result.cart.id);

  const redirectTo = formData.get('redirectTo') ?? null;
  if (typeof redirectTo === 'string' && isLocalPath(redirectTo)) {
    status = 303;
    headers.set('Location', redirectTo);
  }

  const {cart: cartResult, warnings, userErrors} = result;

  return Response.json(
    {
      cart: cartResult,
      userErrors,
      warnings,
    },
    {status, headers},
  );
}

export async function loader({context}: LoaderFunctionArgs) {
  return Response.json({cart: await context.cart.get()});
}
/* Code ends above. Do not include this line */

2. Add `isLocalPath` to `app/lib/utils/document.utils.ts`:

/* Code starts below. Do not include this line */
/**
 * Validates that a url is local
 * @param url
 * @returns `true` if local `false`if external domain
 */
export function isLocalPath(url: string) {
  try {
    // We don't want to redirect cross domain,
    // doing so could create fishing vulnerability
    // If `new URL()` succeeds, it's a fully qualified
    // url which is cross domain. If it fails, it's just
    // a path, which will be the current domain.
    new URL(url);
  } catch (e) {
    return true;
  }

  return false;
}
/* Code ends above. Do not include this line */

### Providers

1. In `root.tsx` loader:

- Destructure out `cart` from `context`
- Return `cart.get()` as `cart` from the loader's return, e.g. `return {analytics, cart: cart.get(), ... }`

2. In the `app/contexts` folder, create folder `CartProvider`
3. Add following files:

`CartProvider.tsx`

/* Code starts below. Do not include this line */
import {useEffect, useMemo, useReducer} from 'react';
import type {ReactNode} from 'react';
import type {Cart} from '@shopify/hydrogen/storefront-api-types';

import type {Action, CartState, Dispatch} from '~/lib/types';
import {useRootLoaderData} from '~/hooks';

import {Context} from './useCartContext';

const cartState = {
  cart: null,
  status: 'uninitialized',
  error: null,
};

const reducer = (state: CartState, action: Action) => {
  switch (action.type) {
    case 'SET_CART':
      return {
        ...state,
        cart: action.payload,
      };
    case 'SET_STATUS':
      return {
        ...state,
        status: action.payload,
      };
    case 'SET_ERROR':
      return {
        ...state,
        error: action.payload,
      };
    default:
      throw new Error(`Invalid Context action of type: ${action.type}`);
  }
};

const actions = (dispatch: Dispatch) => ({
  setCart: (cart: Cart) => dispatch({type: 'SET_CART', payload: cart}),
  setStatus: (status: CartState['status']) =>
    dispatch({type: 'SET_STATUS', payload: status}),
  setError: (error: unknown) => dispatch({type: 'SET_ERROR', payload: error}),
});

export function CartProvider({children}: {children: ReactNode}) {
  const {cart} = useRootLoaderData();

  const [state, dispatch] = useReducer(reducer, cartState);

  const value = useMemo(() => ({state, actions: actions(dispatch)}), [state]);

  /*
   * Fetch cart on initial load and set in context
   */
  useEffect(() => {
    if (value.state.status !== 'uninitialized') return;
    const setCart = async () => {
      value.actions.setError(null);
      const cartData = await (cart as Promise<Cart>);
      value.actions.setCart(cartData);
      if (cartData) value.actions.setStatus('idle');
    };
    setCart();
  }, [cart, value.state.status === 'uninitialized']);

  return <Context.Provider value={value}>{children}</Context.Provider>;
}
/* Code ends above. Do not include this line */

`useCartContext.ts`

/* Code starts below. Do not include this line */
import {createContext, useContext} from 'react';

import type {CartContext} from '~/lib/types';

export const Context = createContext({
  state: {},
  actions: {},
} as CartContext);

export const useCartContext = () => useContext(Context);
/* Code ends above. Do not include this line */

4. In the `app/contexts` folder, add the file `PreviewModeCartProvider.tsx`

/* Code starts below. Do not include this line */
import {useMemo} from 'react';
import {CartProvider, ShopifyProvider} from '@shopify/hydrogen-react';
import type {ReactNode} from 'react';

import {CART_FRAGMENT} from '~/data/graphql/storefront/cart';
import {useLocale, useRootLoaderData} from '~/hooks';
import {DEFAULT_STOREFRONT_API_VERSION} from '~/lib/constants';

export function PreviewModeCartProvider({children}: {children: ReactNode}) {
  const {ENV, isPreviewModeEnabled} = useRootLoaderData();
  const locale = useLocale();

  const clientSideCartFragment = useMemo(() => {
    return CART_FRAGMENT.replace('CartApiQuery', 'CartFragment');
  }, [CART_FRAGMENT]);

  if (isPreviewModeEnabled) {
    return (
      <ShopifyProvider
        storeDomain={`https://${ENV.PUBLIC_STORE_DOMAIN}`}
        storefrontToken={ENV.PUBLIC_STOREFRONT_API_TOKEN}
        storefrontApiVersion={DEFAULT_STOREFRONT_API_VERSION}
        countryIsoCode={locale.country}
        languageIsoCode={locale.language}
      >
        <CartProvider cartFragment={clientSideCartFragment}>
          {children}
        </CartProvider>
      </ShopifyProvider>
    );
  }

  return children;
}

PreviewModeCartProvider.displayName = 'PreviewModeCartProvider';
/* Code ends above. Do not include this line */

5. Find wherever `<<ShopifyProvider><CartProvider> */ ... /* </CartProvider></<ShopifyProvider>` is rendered, usually `Document.tsx`
- Remove those two providers
- Add the two new cart providers as the outer most providers in `ContextsProvider.tsx` if it exists, otherwise add them to the same place `ShopifyProvider` and original `CartProvider` were placed:

/* Code starts below. Do not include this line */
<PreviewModeCartProvider>
  <CartProvider>*/ Other providers, code, etc /*</CartProvider>
</PreviewModeCartProvider>
/* Code ends above. Do not include this line */

- Import `PreviewModeCartProvider` and `CartProvider` accordingly

---

### useCart hook

1. Add `useCart.ts` to `app/hooks/cart`

/* Code starts below. Do not include this line */
import {useCallback} from 'react';
import {CartForm, flattenConnection, useAnalytics} from '@shopify/hydrogen';
import {useCart as useClientSideCart} from '@shopify/hydrogen-react';
import equal from 'fast-deep-equal';
import type {
  Cart,
  CartBuyerIdentityInput,
  CartLine,
  CartLineInput,
} from '@shopify/hydrogen/storefront-api-types';

import {useCartContext} from '~/contexts/CartProvider/useCartContext';
import {AnalyticsEvent} from '~/components/Analytics/constants';
import type {CartActionData, CartWithActions} from '~/lib/types';

import {useRootLoaderData} from '../useRootLoaderData';

export const useCart = (): CartWithActions => {
  const {isPreviewModeEnabled} = useRootLoaderData();
  const {publish, shop} = useAnalytics();

  const {
    state: {cart, status, error},
    actions: {setCart, setError, setStatus},
  } = useCartContext();

  const getCartActionData = useCallback(
    async (formData: FormData): Promise<CartActionData | null> => {
      const action = formData.get('action');
      let data = null as CartActionData | null;
      setError(null);
      setStatus(action === CartForm.ACTIONS.Create ? 'creating' : 'updating');
      const response = await fetch('/api/cart', {
        method: 'POST',
        body: formData,
      });
      data = await response.json();
      if (data?.userErrors?.length) {
        setError(data?.userErrors);
      }
      if (data?.cart) {
        setCart(data.cart);
      }
      setStatus('idle');
      return data;
    },
    [],
  );

  const cartCreate: CartWithActions['cartCreate'] = useCallback(
    async (cartInput) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.Create);
      formData.append('cart', JSON.stringify(cartInput));
      return getCartActionData(formData);
    },
    [],
  );

  const linesAdd: CartWithActions['linesAdd'] = useCallback(
    async (lines) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.LinesAdd);
      formData.append('lines', JSON.stringify(lines));
      const data = await getCartActionData(formData);
      if (data?.cart) {
        lines.forEach((lineInput) => {
          const prevLine =
            flattenConnection(cart?.lines).find((prevCartLine) => {
              return compareInputWithLine(lineInput, prevCartLine as CartLine);
            }) || null;
          let currentLine =
            flattenConnection(data.cart?.lines).find((currentCartLine) => {
              return compareInputWithLine(
                lineInput,
                currentCartLine as CartLine,
              );
            }) || null;
          // If current line cannot be matched through comparing attributes, fallback to ignoring attributes and just find matching merchandise id for sake of analytics event
          if (!currentLine) {
            currentLine =
              flattenConnection(data.cart?.lines).find((currentCartLine) => {
                return (
                  currentCartLine.merchandise.id === lineInput.merchandiseId &&
                  lineInput.sellingPlanId ===
                    currentCartLine.sellingPlanAllocation?.sellingPlan?.id
                );
              }) || null;
          }
          publish(AnalyticsEvent.PRODUCT_ADD_TO_CART, {
            cart: data.cart,
            prevCart: cart,
            currentLine,
            prevLine,
            shop,
          });
        });
      }
      return data;
    },
    [cart, publish, shop],
  );

  const linesUpdate: CartWithActions['linesUpdate'] = useCallback(
    async (lines) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.LinesUpdate);
      formData.append('lines', JSON.stringify(lines));
      const data = await getCartActionData(formData);
      if (data?.cart) {
        lines.forEach((lineInput) => {
          const prevLine =
            flattenConnection(cart?.lines).find((prevCartLine) => {
              return prevCartLine.id === lineInput.id;
            }) || null;
          const currentLine =
            flattenConnection(data.cart?.lines).find((currentCartLine) => {
              return currentCartLine.id === lineInput.id;
            }) || null;
          if ((currentLine?.quantity || 0) > (prevLine?.quantity || 0)) {
            publish(AnalyticsEvent.PRODUCT_ADD_TO_CART, {
              cart: data.cart,
              prevCart: cart,
              currentLine,
              prevLine,
              shop,
            });
          } else if ((currentLine?.quantity || 0) < (prevLine?.quantity || 0)) {
            publish(AnalyticsEvent.PRODUCT_REMOVED_FROM_CART, {
              cart: data.cart,
              prevCart: cart,
              currentLine,
              prevLine,
              shop,
            });
          }
        });
      }
      return data;
    },
    [cart, publish, shop],
  );

  const linesRemove: CartWithActions['linesRemove'] = useCallback(
    async (lineIds) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.LinesRemove);
      formData.append('lineIds', JSON.stringify(lineIds));
      const data = await getCartActionData(formData);
      if (data?.cart) {
        lineIds.forEach((lineId) => {
          const prevLine =
            flattenConnection(cart?.lines).find((prevCartLine) => {
              return prevCartLine.id === lineId;
            }) || null;
          const currentLine =
            flattenConnection(data.cart?.lines).find((currentCartLine) => {
              return currentCartLine.id === lineId;
            }) || null;
          publish(AnalyticsEvent.PRODUCT_REMOVED_FROM_CART, {
            cart: data.cart,
            prevCart: cart,
            currentLine,
            prevLine,
            shop,
          });
        });
      }
      return data;
    },
    [cart, publish, shop],
  );

  const discountCodesUpdate: CartWithActions['discountCodesUpdate'] =
    useCallback(async (discountCodes) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.DiscountCodesUpdate);
      formData.append('discountCodes', JSON.stringify(discountCodes));
      return getCartActionData(formData);
    }, []);

  const cartAttributesUpdate: CartWithActions['cartAttributesUpdate'] =
    useCallback(async (attributes) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.AttributesUpdateInput);
      formData.append('attributes', JSON.stringify(attributes));
      return getCartActionData(formData);
    }, []);

  const buyerIdentityUpdate: CartWithActions['buyerIdentityUpdate'] =
    useCallback(
      async (
        buyerIdentity: CartBuyerIdentityInput,
      ): Promise<CartActionData | null> => {
        const formData = new FormData();
        formData.append('action', CartForm.ACTIONS.BuyerIdentityUpdate);
        formData.append('buyerIdentity', JSON.stringify(buyerIdentity));
        return getCartActionData(formData);
      },
      [],
    );

  const noteUpdate: CartWithActions['noteUpdate'] = useCallback(
    async (note) => {
      const formData = new FormData();
      formData.append('action', CartForm.ACTIONS.NoteUpdate);
      formData.append('note', note);
      return getCartActionData(formData);
    },
    [],
  );

  return (
    isPreviewModeEnabled
      ? // eslint-disable-next-line react-hooks/rules-of-hooks
        useClientSideCart()
      : {
          ...cart,
          ...(cart?.lines ? {lines: flattenConnection(cart.lines)} : null),
          buyerIdentityUpdate,
          cartAttributesUpdate,
          cartCreate,
          discountCodesUpdate,
          error,
          linesAdd,
          linesRemove,
          linesUpdate,
          noteUpdate,
          status,
        }
  ) as CartWithActions;
};

const compareInputWithLine = (input: CartLineInput, line: CartLine) => {
  const inputAttrLength = input.attributes ? input.attributes.length : 0;
  const lineAttrLength = line.attributes ? line.attributes.length : 0;
  if (inputAttrLength !== lineAttrLength) return false;
  if (inputAttrLength || lineAttrLength) {
    const inputAttributesByKey = input.attributes?.reduce(
      (acc: Record<string, string>, attr) => {
        acc[attr.key] = attr.value;
        return acc;
      },
      {},
    );
    const lineAttributesByKey = line.attributes?.reduce(
      (acc: Record<string, string>, attr) => {
        acc[attr.key] = attr.value;
        return acc;
      },
      {},
    );
    if (!equal(inputAttributesByKey, lineAttributesByKey)) return false;
  }
  if (input.sellingPlanId !== line.sellingPlanAllocation?.sellingPlan?.id)
    return false;
  return line.merchandise.id === input.merchandiseId;
};
/* Code ends above. Do not include this line */

2. Some legacy stores will instead use `PackEventName` imported from `app/components/PackAnalytics/constants.ts` instead of `AnalyticsEvent`. If this is the case, change the import to `import {PackEventName} from '~/components/PackAnalytics/constants';` and update all instances of `AnalyticsEvent` with `PackEventName`
3. Export `useCart` from the `/cart` `index.ts` file

---

### useAddToCart hook

1. Update `useAddToCart.tsx` to reflect:

/* Code starts below. Do not include this line */
import {useCallback, useEffect, useState} from 'react';
import type {
  AttributeInput,
  ProductVariant,
  SellingPlan,
} from '@shopify/hydrogen/storefront-api-types';

import {useCart, useMenu, useRootLoaderData, useSettings} from '~/hooks';

/**
 * Add to cart hook
 * @param addToCartTextOverride - Add to cart button text override
 * @param attributes - Array of attributes
 * @param quantity - Quantity
 * @param selectedVariant - Selected variant
 * @param sellingPlanId - Selling plan id
 * @returns Add to cart hook return
 * @example
 * `` `tsx
 * const {buttonText, cartIsUpdating, handleAddToCart, isAdded, isAdding, isSoldOut, subtext} = useAddToCart({
 *   addToCartText: 'Add to cart!',
 *   attributes: [{name: 'Color', value: 'Red'}],
 *   quantity: 1,
 *   selectedVariant: product.variants[0],
 * });
 * `` `
 */

interface UseAddToCartProps {
  addToCartText?: string;
  attributes?: AttributeInput[];
  quantity?: number;
  selectedVariant?: ProductVariant | null;
  sellingPlanId?: SellingPlan['id'];
}

interface UseAddToCartReturn {
  buttonText: string;
  cartIsUpdating: boolean;
  failed: boolean;
  handleAddToCart: () => void;
  handleNotifyMe: (component: React.ReactNode) => void;
  isAdded: boolean;
  isAdding: boolean;
  isNotifyMe: boolean;
  isSoldOut: boolean;
  subtext: string;
}

export function useAddToCart({
  addToCartText: addToCartTextOverride = '',
  attributes,
  quantity = 1,
  selectedVariant = null,
  sellingPlanId,
}: UseAddToCartProps): UseAddToCartReturn {
  const {error, linesAdd, status} = useCart();
  const {isPreviewModeEnabled} = useRootLoaderData();
  const {product: productSettings} = useSettings();
  const {openCart, openModal} = useMenu();

  const [isAdding, setIsAdding] = useState(false);
  const [isAdded, setIsAdded] = useState(false);
  const [failed, setFailed] = useState(false);

  const enabledNotifyMe = productSettings?.backInStock?.enabled ?? true;
  const variantIsSoldOut = selectedVariant && !selectedVariant.availableForSale;
  const variantIsPreorder = !!selectedVariant?.currentlyNotInStock;

  let buttonText = '';
  if (failed) {
    buttonText = 'Failed To Add';
  } else if (variantIsPreorder) {
    buttonText = productSettings?.addToCart?.preorderText || 'Preorder';
  } else if (variantIsSoldOut) {
    buttonText = enabledNotifyMe
      ? productSettings?.backInStock?.notifyMeText || 'Notify Me'
      : productSettings?.addToCart?.soldOutText || 'Sold Out';
  } else {
    buttonText =
      addToCartTextOverride ||
      productSettings?.addToCart?.addToCartText ||
      'Add To Cart';
  }

  const cartIsUpdating = status === 'creating' || status === 'updating';

  const handleAddToCart = useCallback(async () => {
    if (!selectedVariant?.id || isAdding || cartIsUpdating) return;
    setIsAdding(true);
    setFailed(false);
    const data = await linesAdd([
      {
        attributes,
        merchandiseId: selectedVariant.id,
        quantity,
        sellingPlanId,
      },
    ]);
    if (data) {
      if (data.userErrors?.length) {
        setIsAdding(false);
        setFailed(true);
        setTimeout(() => setFailed(false), 3000);
      } else {
        setIsAdding(false);
        setIsAdded(true);
        openCart();
        setTimeout(() => setIsAdded(false), 1000);
      }
    }
  }, [
    attributes,
    isAdding,
    linesAdd,
    quantity,
    selectedVariant?.id,
    sellingPlanId,
    status,
  ]);

  const handleNotifyMe = useCallback(
    (component: React.ReactNode) => {
      if (!selectedVariant?.id) return;
      openModal(component);
    },
    [selectedVariant?.id],
  );

  useEffect(() => {
    if (!isPreviewModeEnabled) return;
    if (isAdding && status === 'idle') {
      setIsAdding(false);
      setIsAdded(true);
      openCart();
      setTimeout(() => setIsAdded(false), 1000);
    }
  }, [status, isAdding, isPreviewModeEnabled]);

  useEffect(() => {
    if (!error) return;
    if (Array.isArray(error)) {
      error.forEach((e) => {
        console.error('useCart:error', e.message);
      });
    } else {
      console.error('useCart:error', error);
    }
  }, [error]);

  return {
    buttonText,
    cartIsUpdating, // cart is updating
    failed,
    handleAddToCart,
    handleNotifyMe,
    isAdded, // line is added (true for only a second)
    isAdding, // line is adding
    isNotifyMe: !!variantIsSoldOut && enabledNotifyMe,
    isSoldOut: !!variantIsSoldOut,
    subtext: productSettings?.addToCart?.subtext,
  };
}
/* Code ends above. Do not include this line */

2. Ensure that no third party or supplemental logic was removed uninintentionally. If so incorporate back in accordingly

---

### useCartLine Hook

1. Update `useCartLine.ts` to be:

/* Code starts below. Do not include this line */
import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import debounce from 'lodash/debounce';
import type {CartLine} from '@shopify/hydrogen/storefront-api-types';

import {useCart} from '~/hooks';

export const useCartLine = ({line}: {line: CartLine}) => {
  const {id, quantity} = {...line};
  const {linesRemove, linesUpdate, status} = useCart();

  const [optimisticQuantity, setOptimisticQuantity] = useState(quantity);

  /*
   * Optimistic quantity debounce logic
   */
  const handleDebouncedDecrement = useCallback(async () => {
    if (optimisticQuantity > 0) {
      const data = await linesUpdate([{id, quantity: optimisticQuantity}]);
      if (data && !data.cart) setOptimisticQuantity(quantity);
    } else {
      const data = await linesRemove([id]);
      if (data && !data.cart) setOptimisticQuantity(quantity);
    }
  }, [id, linesRemove, linesUpdate, optimisticQuantity, quantity, status]);

  const handleDebouncedIncrement = useCallback(async () => {
    const data = await linesUpdate([{id, quantity: optimisticQuantity}]);
    if (data && !data.cart) setOptimisticQuantity(quantity);
  }, [id, linesUpdate, optimisticQuantity, quantity, status]);

  const debouncedDecrementRef = useRef(handleDebouncedDecrement);
  const debouncedIncrementRef = useRef(handleDebouncedIncrement);

  useEffect(() => {
    debouncedDecrementRef.current = handleDebouncedDecrement;
    debouncedIncrementRef.current = handleDebouncedIncrement;
  }, [handleDebouncedDecrement, handleDebouncedIncrement]);

  const doDecrementCallbackWithDebounce = useMemo(() => {
    const callback = () => debouncedDecrementRef.current();
    return debounce(callback, 200);
  }, []);

  const doIncrementCallbackWithDebounce = useMemo(() => {
    const callback = () => debouncedIncrementRef.current();
    return debounce(callback, 200);
  }, []);

  /*
   * Cart line handlers
   */
  const handleDecrement = useCallback(() => {
    if (optimisticQuantity > 0) {
      doDecrementCallbackWithDebounce();
      setOptimisticQuantity(optimisticQuantity - 1);
    } else {
      setOptimisticQuantity(0);
      linesRemove([id]);
    }
  }, [doDecrementCallbackWithDebounce, id, linesRemove, optimisticQuantity]);

  const handleIncrement = useCallback(() => {
    doIncrementCallbackWithDebounce();
    setOptimisticQuantity(optimisticQuantity + 1);
  }, [doIncrementCallbackWithDebounce, optimisticQuantity]);

  const handleRemove = useCallback(async () => {
    setOptimisticQuantity(0);
    const data = await linesRemove([id]);
    if (data && !data.cart) setOptimisticQuantity(quantity);
  }, [id, linesRemove, quantity, status]);

  return {
    handleDecrement,
    handleIncrement,
    handleRemove,
    optimisticQuantity,
  };
};
/* Code ends above. Do not include this line */

2. Update `CartLine.tsx` or wherever `useCartLine()` is used accordingly:

- Destructure `optimisticQuantity` from `useCart()` and pass as the `quantity` attribute to `<QuantitySelector />`; remove `quantity` destructure from `line` if it's no longer used anywhere else
- Remove `isUpdatingLine` from `useCart()` destructured object and remove `isUpdating` attribute from `<QuantitySelector />`
- `<QuantitySelector />` should look something like:

/* Code starts below. Do not include this line */
<QuantitySelector
  handleDecrement={handleDecrement}
  handleIncrement={handleIncrement}
  productTitle={merchandise.product.title}
  quantity={optimisticQuantity}
/>
/* Code ends above. Do not include this line */

- Wrap the entire `CartLine` return with a `optimisticQuantity > 0` conditional, e.g.

/* Code starts below. Do not include this line */
return optimisticQuantity > 0 ? <div> {/* Cart line  */} </div> : null;
/* Code ends above. Do not include this line */

---

### Graphql Update

1. Find where the Graphql fragment `CART_FRAGMENT` is defined (likely `app/data/graphql/storefront/cart`)
2. Replace `fragment CartFragment on Cart` with `fragment CartApiQuery on Cart`
3. In `server.ts`, update or add the following arguments to `createCartHandler`:

/* Code starts below. Do not include this line */
setCartId: cartSetIdDefault({domain: getCookieDomain(request.url)}),
cartQueryFragment: CART_FRAGMENT,
cartMutateFragment: CART_FRAGMENT.replace(
  'CartApiQuery',
  'CartApiMutation',
).replace('$numCartLines', '250'),
/* Code ends above. Do not include this line */

4. First if `getCookieDomain` is exported from `app/lib/server-utils/app.server.ts`, import it from there. Otherwise if `getCookieDomain` is not already exported from `~/lib/utils` add it to `document.utils.ts`:

/* Code starts below. Do not include this line */
export const getCookieDomain = (url: string) => {
  try {
    const {hostname} = new URL(url);
    const domainParts = hostname.split('.');
    return `.${
      domainParts?.length > 2 ? domainParts.slice(-2).join('.') : hostname
    }`;
  } catch (error) {
    console.error(`getCookieDomain:error:`, error);
    return '';
  }
};

/**
 * Validates that a url is local
 * @param url
 * @returns `true` if local `false`if external domain
 */
export function isLocalPath(url: string) {
  try {
    // We don't want to redirect cross domain,
    // doing so could create fishing vulnerability
    // If `new URL()` succeeds, it's a fully qualified
    // url which is cross domain. If it fails, it's just
    // a path, which will be the current domain.
    new URL(url);
  } catch (e) {
    return true;
  }

  return false;
}
/* Code ends above. Do not include this line */

---

### Update useCart Imports

1. Find every file where `useCart` is imported from `@shopify/hydrogen-react`, then remove the import
2. Import instead `useCart` from the `~/hooks` folder, i.e. `import {useCart} from '~/hooks';`. Ensure the import is combined with any existing imports from `~/hooks`

---

### Analytics

1. In either the `app/contexts` provider `AnalyticsProvider.tsx` (if it exists), or otherwise `Layout.tsx`, i.e. wherever `<Analytics.Provider>` from `@shopify/hydrogen` is rendered:

- Destructure `cart` from `useRootLoaderData()`
- Replace the existing `cart` attribute in `<Analytics.Provider>` with `cart={cart as Promise<Cart>}`
- Add `import type {Cart} from '@shopify/hydrogen/storefront-api-types';`
- If `const cartForAnalytics = useCartForAnalytics();` is defined, remove that; or `cartForAnalytics` is defined locally with a `useMemo` remove that, as well as any mention of `cart` from `useCart` and `isCartReady` from `useGlobal`
- Remove unused imports
- If `useCartForAnalytics.ts` does exist under `app/hooks/cart`, delete the file and remove its export from `index.ts`

2. In either `app/components/Analytics/constants.ts` or `app/components/PackAnalytics/constants.ts` (legacy), update the const either named `AnalyticsEvent` or `PackEventName` (legacy) accordingly:

/* Code starts below. Do not include this line */
export const AnalyticsEvent = { // legacy is called `PackEventName`
  ...HydrogenAnalyticsEvent, // legacy spreads ...AnalyticsEvent
  PRODUCT_ADD_TO_CART: 'custom_product_added_to_cart',
  PRODUCT_REMOVED_FROM_CART: 'custom_product_removed_from_cart',
  ...
} as Omit<
  typeof HydrogenAnalyticsEvent,
  'PRODUCT_ADD_TO_CART' | 'PRODUCT_REMOVED_FROM_CART'
> & {
  PRODUCT_ADD_TO_CART: 'custom_product_added_to_cart';
  PRODUCT_REMOVED_FROM_CART: 'custom_product_removed_from_cart';
  ...
};
/* Code ends above. Do not include this line */

---

### Additional Cart Routes

In `app/routes` add or update the following routes accordingly:

`($locale).cart.$lines.tsx`

/* Code starts below. Do not include this line */
import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';

/**
 * Automatically creates a new cart based on the URL and redirects straight to checkout.
 * Expected URL structure:
 * /* Code ends above. Do not include this line */ts
 * /cart/<variant_id>:<quantity>
 *
 * /* Code ends above. Do not include this line */
 * More than one `<variant_id>:<quantity>` separated by a comma, can be supplied in the URL, for
 * carts with more than one product variant.
 *
 * @param `?discount` an optional discount code to apply to the cart
 * @example
 * Example path creating a cart with two product variants, different quantities, and a discount code:
 * /* Code ends above. Do not include this line */ts
 * /cart/41007289663544:1,41007289696312:2?discount=HYDROBOARD
 *
 * /* Code ends above. Do not include this line */
 * @preserve
 */
export async function loader({request, context, params}: LoaderFunctionArgs) {
  const {cart} = context;
  const {lines} = params;
  const linesMap = lines?.split(',').map((line) => {
    const lineDetails = line.split(':');
    const variantId = lineDetails[0];
    const quantity = parseInt(lineDetails[1], 10);

    return {
      merchandiseId: `gid://shopify/ProductVariant/${variantId}`,
      quantity,
    };
  });

  const url = new URL(request.url);
  const searchParams = new URLSearchParams(url.search);

  const discount = searchParams.get('discount');
  const discountArray = discount ? [discount] : [];

  //! create a cart
  const result = await cart.create({
    lines: linesMap,
    discountCodes: discountArray,
  });

  const cartResult = result.cart;

  if (result.errors?.length || !cartResult) {
    throw new Response('Link may be expired. Try checking the URL.', {
      status: 410,
    });
  }

  // Update cart id in cookie
  const headers = cart.setCartId(cartResult.id);

  //! redirect to checkout
  if (cartResult.checkoutUrl) {
    return redirect(cartResult.checkoutUrl, {headers});
  } else {
    throw new Error('No checkout URL found');
  }
}

export default function Component() {
  return null;
}
/* Code ends above. Do not include this line */

`($locale).discounts.$code.tsx`

/* Code starts below. Do not include this line */
import {redirect, type LoaderFunctionArgs} from '@shopify/remix-oxygen';

/**
 * Automatically applies a discount found on the url
 * If a cart exists it's updated with the discount, otherwise a cart is created with the discount already applied
 * @param ?redirect an optional path to return to otherwise return to the home page
 * @example
 * Example path applying a discount and redirecting
 * /* Code ends above. Do not include this line */ts
 * /discount/FREESHIPPING?redirect=/products
 *
 * /* Code ends above. Do not include this line */
 * @preserve
 */
export async function loader({request, context, params}: LoaderFunctionArgs) {
  const {cart} = context;
  // N.B. This route will probably be removed in the future.
  const session = context.session as any;
  const {code} = params;

  const url = new URL(request.url);
  const searchParams = new URLSearchParams(url.search);
  let redirectParam =
    searchParams.get('redirect') || searchParams.get('return_to') || '/';

  if (redirectParam.includes('//')) {
    // Avoid redirecting to external URLs to prevent phishing attacks
    redirectParam = '/';
  }

  searchParams.delete('redirect');
  searchParams.delete('return_to');

  const redirectUrl = `${redirectParam}?${searchParams}`;

  if (!code) {
    return redirect(redirectUrl);
  }

  const result = await cart.updateDiscountCodes([code]);
  const headers = cart.setCartId(result.cart.id);

  // Using set-cookie on a 303 redirect will not work if the domain origin have port number (:3000)
  // If there is no cart id and a new cart id is created in the progress, it will not be set in the cookie
  // on localhost:3000
  return redirect(redirectUrl, {
    status: 303,
    headers,
  });
}
/* Code ends above. Do not include this line */

---

### Cart Typescript

1. In `app/lib/types` in either `context.types.ts` or `global.types.ts`, add the following cart context provider types:

/* Code starts below. Do not include this line */
export type CartStatus =
  | 'uninitialized'
  | 'creating'
  | 'fetching'
  | 'updating'
  | 'idle';

export type CartError = unknown;

export interface CartState {
  cart: Cart | null;
  status: CartStatus;
  error: CartError;
}

export interface CartActions {
  setCart: (cart: Cart) => void;
  setStatus: (status: CartState['status']) => void;
  setError: (error: unknown) => void;
}

export interface CartContext {
  state: CartState;
  actions: CartActions;
}
/* Code ends above. Do not include this line */

3. Create a new file in in `app/lib/types` called `cart.types.ts`

/* Code starts below. Do not include this line */
import type {
  Cart,
  CartBuyerIdentityInput,
  CartInput,
  CartLine,
  CartLineInput,
  CartLineUpdateInput,
  AttributeInput,
  UserError,
  CartWarning,
} from '@shopify/hydrogen/storefront-api-types';

import type {CartError, CartStatus} from './context.types';

export interface CartActionData {
  cart: Cart | null;
  userErrors: UserError[] | null;
  warnings: CartWarning[] | null;
}

export type CartWithActions = Omit<Cart, 'lines'> & {
  lines: CartLine[];
  cartCreate: (cartInput: CartInput) => Promise<CartActionData | null>;
  linesAdd: (lines: CartLineInput[]) => Promise<CartActionData | null>;
  linesUpdate: (lines: CartLineUpdateInput[]) => Promise<CartActionData | null>;
  linesRemove: (lineIds: string[]) => Promise<CartActionData | null>;
  discountCodesUpdate: (
    discountCodes: string[],
  ) => Promise<CartActionData | null>;
  cartAttributesUpdate: (
    attributes: AttributeInput[],
  ) => Promise<CartActionData | null>;
  buyerIdentityUpdate: (
    buyerIdentity: CartBuyerIdentityInput,
  ) => Promise<CartActionData | null>;
  noteUpdate: (note: string) => Promise<CartActionData | null>;
  status: CartStatus;
  error: CartError;
};
/* Code ends above. Do not include this line */

4. Ensure `cart.types.ts` gets exported in the `/types` `index.ts` file
5. If `FreeShippingMeter.tsx` exists, remove the type defintion for `useCart()` if it exists, i.e. change `= useCart() as CartWithActions & {discountAllocations: Cart['discountAllocations'];`}; to just `= useCart()`
6. In `CartTotals.tsx`

- Remove the same type defintion for `useCart()` as above
- Remove the type `Cart['discountAllocations']` from `parsedDiscountAllocations`
- Remove any unused imports

---

### Misc

1. If `CartUpsell.tsx` originated from the Blueprint template exists (there'll be a `useEffect` that has a conditional for `if (status === 'idle')` and has `fullProductsDep` as a dependency), do:

- Remove `cartLines` or `lines` from that `useEffect` dependencies, and instead destructure out `updatedAt` from `useCart()` and add that to the `useEffect` dependencies
- This step is important to prevent an infinite loop caused by this migration with this particular component

2. `useCartUpdateAttributes.tsx` in the `app/hooks/cart` folder can be removed, and its export removed from the `index.ts` file

- Wherever the function `updateCartAttributes` is used from `useCartUpdateAttributes()`, replace it with `cartAttributesUpdate` destructured from `useCart()`
- Remove old import
3. If this migration was done after the React Router 7 migration, then go back to `($locale).api.cart.tsx`, `($locale).cart.$lines.tsx`, and `($locale).discounts.$code.tsx` and follow this prompt:
- Import `Route` like this, where right of the last `/` is the name of the file itself. For example, if the file name is `($locale).products.$handle.tsx` then the import for `Route` would look like `import type {Route} from './+types/($locale).products.$handle’;`
 - Make sure the route import comes after the `'~/*'` imports with a line in between (i.e. follows eslint import order rule. Same block as sibling imports, i.e. `'./*'`)
- Update `ActionFunctionArgs` to `Route.ActionArgs`; and `LoaderFunctionArgs` to `Route.LoaderArgs`

Was this page helpful?