Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/cart-warnings-feedback.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/cli-hydrogen': patch
'skeleton': patch
---

Add cart warnings component to display feedback to users when there are issues with their cart. Includes new `InlineFeedback` component and a `CartWarnings` component for collecting and displaying cart errors and warnings in an accessible way.
2 changes: 1 addition & 1 deletion e2e/specs/smoke/cart.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {setTestStore, test, expect} from '../../fixtures';
import {setTestStore, test, expect, Page} from '../../fixtures';

setTestStore('mockShop');

Expand Down
74 changes: 67 additions & 7 deletions templates/skeleton/app/components/CartLineItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,10 @@ import type {CartLineUpdateInput} from '@shopify/hydrogen/storefront-api-types';
import type {CartLayout, LineItemChildrenMap} from '~/components/CartMain';
import {CartForm, Image, type OptimisticCartLine} from '@shopify/hydrogen';
import {useVariantUrl} from '~/lib/variants';
import {Link} from 'react-router';
import {href, Link, useFetcher, type FetcherWithComponents} from 'react-router';
import {ProductPrice} from './ProductPrice';
import {useAside} from './Aside';
import type {
CartApiQueryFragment,
CartLineFragment,
} from 'storefrontapi.generated';
import type {CartApiQueryFragment} from 'storefrontapi.generated';

export type CartLine = OptimisticCartLine<CartApiQueryFragment>;

Expand Down Expand Up @@ -98,13 +95,13 @@ export function CartLineItem({
*/
function CartLineQuantity({line}: {line: CartLine}) {
if (!line || typeof line?.quantity === 'undefined') return null;

const {id: lineId, quantity, isOptimistic} = line;
const prevQuantity = Number(Math.max(0, quantity - 1).toFixed(0));
const nextQuantity = Number((quantity + 1).toFixed(0));

return (
<div className="cart-line-quantity">
<small>Quantity: {quantity} &nbsp;&nbsp;</small>
<span>Quantity: &nbsp;&nbsp;</span>
<CartLineUpdateButton lines={[{id: lineId, quantity: prevQuantity}]}>
<button
aria-label="Decrease quantity"
Expand All @@ -116,6 +113,8 @@ function CartLineQuantity({line}: {line: CartLine}) {
</button>
</CartLineUpdateButton>
&nbsp;
<CartLineQuantityInput disabled={!!isOptimistic} line={line} />
&nbsp;
<CartLineUpdateButton lines={[{id: lineId, quantity: nextQuantity}]}>
<button
aria-label="Increase quantity"
Expand Down Expand Up @@ -158,6 +157,67 @@ function CartLineRemoveButton({
);
}

function isKeyboardEvent(
e: React.ChangeEvent<HTMLInputElement>,
): e is typeof e & {nativeEvent: InputEvent} {
if (e.nativeEvent instanceof InputEvent) {
if (
e.nativeEvent.inputType === 'insertText' ||
e.nativeEvent.inputType === 'deleteContentBackward' ||
e.nativeEvent.inputType === 'deleteContentForward'
) {
return true;
}
}
return false;
}

async function submitQuantity(
e: React.ChangeEvent<HTMLInputElement>,
fetcher: FetcherWithComponents<any>,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's not strictly enforced right now, but I think we should never add any anys into the codebase
This could be properly typed using the cart action type:

import type {action as cartAction} from '~/routes/cart';
type CartActionResponse = Awaited<ReturnType<typeof cartAction>>;

fetcher: FetcherWithComponents<CartActionResponse>

line: CartLine,
) {
const value = e.target.valueAsNumber;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing NaN validation

e.target.valueAsNumber returns NaN if the input is empty or invalid. This would submit a cart update with quantity: NaN.

async function submitQuantity(
  e: React.ChangeEvent<HTMLInputElement>,
  fetcher: FetcherWithComponents<any>,
  line: CartLine,
) {
  const value = e.target.valueAsNumber;
  if (Number.isNaN(value) || value < 1) {
    return; // Don't submit invalid quantities
  }
  // ... rest
}

const formData = new FormData();
formData.set(
CartForm.INPUT_NAME,
JSON.stringify({
action: CartForm.ACTIONS.LinesUpdate,
inputs: {lines: [{id: line.id, quantity: value}]},
}),
);
await fetcher.submit(formData, {method: 'post', action: href('/cart')});
}

function CartLineQuantityInput({
line,
disabled,
}: {
line: CartLine;
disabled: boolean;
}) {
const fetcher = useFetcher({key: getUpdateKey([line.id])});

return (
<input
aria-label="Quantity"
min={1}
className="cart-line-quantity-input"
disabled={disabled}
key={line.quantity}
type="number"
defaultValue={line.quantity}
onChange={(e) => {
if (isKeyboardEvent(e)) return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If a user changes quantity via up/down arrows (triggering onChange), then clicks away (triggering onBlur), both handlers submit the same value.

The fetcher key deduplication mitigates this, but it's still unnecessary network traffic. Consider tracking whether a pending change exists:

onChange={(e) => {
  if (isKeyboardEvent(e)) return; // Keyboard waits for blur
  void submitQuantity(e, fetcher, line);
}}
onBlur={(e) => {
  // Only submit if there's a pending keyboard change
  // Current implementation submits unconditionally
  void submitQuantity(e, fetcher, line);
}}

void submitQuantity(e, fetcher, line);
}}
onBlur={(e) => {
void submitQuantity(e, fetcher, line);
}}
/>
);
}

function CartLineUpdateButton({
children,
lines,
Expand Down
2 changes: 2 additions & 0 deletions templates/skeleton/app/components/CartMain.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {CartApiQueryFragment} from 'storefrontapi.generated';
import {useAside} from '~/components/Aside';
import {CartLineItem, type CartLine} from '~/components/CartLineItem';
import {CartSummary} from './CartSummary';
import {CartWarnings} from './CartWarnings';

export type CartLayout = 'page' | 'aside';

Expand Down Expand Up @@ -52,6 +53,7 @@ export function CartMain({layout, cart: originalCart}: CartMainProps) {
return (
<div className={className}>
<CartEmpty hidden={linesCount} layout={layout} />
<CartWarnings />
<div className="cart-details">
<div aria-labelledby="cart-lines">
<ul>
Expand Down
111 changes: 111 additions & 0 deletions templates/skeleton/app/components/CartWarnings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {InlineFeedback} from './InlineFeedback';
import {href, useActionData, useFetchers, type Fetcher} from 'react-router';
import type {action as cartAction} from '~/routes/cart';
import {useEffect, useState} from 'react';

function isCartFetcher(
fetcher: Fetcher,
): fetcher is Fetcher<CartActionData> & {key: string} {
return fetcher.formAction === href('/cart') && fetcher.formMethod === 'POST';
}

type CartActionData = NonNullable<
ReturnType<typeof useActionData<typeof cartAction>>
>;
/** Returns the errors and warnings from the cart fetchers
* Normalizes the errors to provide better UX.
*
* Errors are normalized by path, warnings are normalized by code.
*/
export function useCartFeedback() {
const [fetcherDataMap, setFetcherDataMap] = useState<
Map<string, CartActionData>
>(new Map());
const fetchers = useFetchers();

useEffect(() => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetcherDataMap is in the dependency array but also being updated inside this effect. In the 'submitting' case, changed is unconditionally set to true and a new empty Map is created every time. Since new Map() !== new Map() (reference inequality), React sees this as a state change even though both are empty.

The loop is prevented by the fetcher transitioning to 'loading' state quickly, not by the changed flag. This makes the code fragile — it relies on timing behaviour of React Router rather than explicit loop protection.

The 'loading' case is safer because the data comparison (fetcher.data !== newFetcherDataMap.get(fetcher.key)) naturally terminates the loop once data is stored.

Consider using the functional update pattern instead:

useEffect(() => {
  setFetcherDataMap((prevMap) => {
    let changed = false;
    const newMap = new Map(prevMap);

    for (const fetcher of fetchers) {
      if (!isCartFetcher(fetcher)) continue;

      switch (fetcher.state) {
        case 'submitting':
          if (prevMap.size > 0) {
            return new Map(); // Clear on new submission
          }
          break;
        case 'loading':
          if (fetcher.data && fetcher.data !== prevMap.get(fetcher.key)) {
            changed = true;
            newMap.set(fetcher.key, fetcher.data);
          }
          break;
      }
    }

    return changed ? newMap : prevMap;
  });
}, [fetchers]); // fetcherDataMap removed from deps

let changed = false;
let newFetcherDataMap = new Map(fetcherDataMap);

for (const fetcher of fetchers) {
if (!isCartFetcher(fetcher)) continue;

switch (fetcher.state) {
case 'submitting':
newFetcherDataMap = new Map();
changed = true;
break;
case 'loading':
if (
fetcher.data &&
fetcher.data !== newFetcherDataMap.get(fetcher.key)
) {
changed = true;
newFetcherDataMap.set(fetcher.key, fetcher.data);
}
break;
default:
break;
}
}

if (changed) {
setFetcherDataMap(newFetcherDataMap);
}
}, [fetchers, fetcherDataMap]);

const feedback: {
errors: Map<string, NonNullable<CartActionData['errors']>[number]>;
warnings: Map<string, NonNullable<CartActionData['warnings']>[number]>;
userErrors: Map<string, NonNullable<CartActionData['userErrors']>[number]>;
} = {errors: new Map(), warnings: new Map(), userErrors: new Map()};
for (const fetcherData of fetcherDataMap.values()) {
if (fetcherData.warnings) {
for (const warning of fetcherData.warnings) {
feedback.warnings.set(warning.code, warning);
}
}
if (fetcherData.errors) {
for (const error of fetcherData.errors) {
feedback.errors.set(error.path?.join('.') ?? '_root', error);
}
}
if (fetcherData.userErrors) {
for (const userError of fetcherData.userErrors) {
feedback.userErrors.set(userError.code ?? '_root', userError);
}
}
}
return {
errors: Array.from(feedback.errors.values()),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The useCartFeedback hook returns errors (line 80), but the CartWarnings component only renders warnings and userErrors.

Is this intentional? If errors shouldn't display here, consider removing them from the hook's return value to avoid confusion. If they should display, we're missing that rendering.

warnings: Array.from(feedback.warnings.values()),
userErrors: Array.from(feedback.userErrors.values()),
};
}

/** Renders a list of warnings from the cart if there are any */
export function CartWarnings() {
const feedback = useCartFeedback();
if (feedback.warnings.length === 0 && feedback.userErrors.length === 0) {
return null;
}

return (
<div className="cart-warnings">
{feedback.warnings.map((warning) => (
<InlineFeedback
key={warning.code}
type="warning"
title={warning.message}
/>
))}
{feedback.userErrors.map((userError) => (
<InlineFeedback
key={userError.code}
type="error"
title={userError.message}
/>
))}
</div>
);
}
31 changes: 31 additions & 0 deletions templates/skeleton/app/components/InlineFeedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
interface InlineFeedbackProps {
type?: 'warning' | 'error';
title: string;
description?: string;
}

/**
* An accessible inline feedback component for warnings and errors.
* Uses role="alert" to announce changes to assistive technology.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JSDoc doesn't match implementation

The comment says:

Uses role="alert" to announce changes to assistive technology.

But line 19 uses role="status".

Either update the comment to match, or consider making the role conditional:

role={type === 'error' ? 'alert' : 'status'}

Previously I had proposed using `role="status"` but I looked into this more and apparently "alert" is preferred for error messages, whereas "status" is preferred for non-error feedback (eg: cart warnings)

*/
export function InlineFeedback({
type = 'warning',
title,
description,
}: InlineFeedbackProps) {
const icon = type === 'error' ? '✕' : '⚠';

return (
<div className={`inline-feedback inline-feedback--${type}`} role="status">
<span className="inline-feedback-icon" aria-hidden="true">
{icon}
</span>
<div className="inline-feedback-content">
<p className="inline-feedback-title">{title}</p>
{description ? (
<p className="inline-feedback-description">{description}</p>
) : null}
</div>
</div>
);
}
6 changes: 2 additions & 4 deletions templates/skeleton/app/components/PageLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,12 @@ import type {
import {Aside} from '~/components/Aside';
import {Footer} from '~/components/Footer';
import {Header, HeaderMenu} from '~/components/Header';
import {CartMain} from '~/components/CartMain';
import {
SEARCH_ENDPOINT,
SearchFormPredictive,
} from '~/components/SearchFormPredictive';
import {SearchResultsPredictive} from '~/components/SearchResultsPredictive';
import {CartMain} from './CartMain';

interface PageLayoutProps {
cart: Promise<CartApiQueryFragment | null>;
Expand Down Expand Up @@ -60,9 +60,7 @@ function CartAside({cart}: {cart: PageLayoutProps['cart']}) {
<Aside type="cart" heading="CART">
<Suspense fallback={<p>Loading cart ...</p>}>
<Await resolve={cart}>
{(cart) => {
return <CartMain cart={cart} layout="aside" />;
}}
{(cart) => <CartMain cart={cart} layout="aside" />}
</Await>
</Suspense>
</Aside>
Expand Down
9 changes: 3 additions & 6 deletions templates/skeleton/app/routes/cart.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,4 @@
import {
useLoaderData,
data,
type HeadersFunction,
} from 'react-router';
import {useLoaderData, data, type HeadersFunction} from 'react-router';
import type {Route} from './+types/cart';
import type {CartQueryDataReturn} from '@shopify/hydrogen';
import {CartForm} from '@shopify/hydrogen';
Expand Down Expand Up @@ -79,7 +75,7 @@ export async function action({request, context}: Route.ActionArgs) {

const cartId = result?.cart?.id;
const headers = cartId ? cart.setCartId(result.cart.id) : new Headers();
const {cart: cartResult, errors, warnings} = result;
const {cart: cartResult, errors, warnings, userErrors} = result;

const redirectTo = formData.get('redirectTo') ?? null;
if (typeof redirectTo === 'string') {
Expand All @@ -91,6 +87,7 @@ export async function action({request, context}: Route.ActionArgs) {
{
cart: cartResult,
errors,
userErrors,
warnings,
analytics: {
cartId,
Expand Down
Loading
Loading