Skip to content
Closed
95 changes: 95 additions & 0 deletions .changeset/gift-card-add-mutation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
---
'@shopify/hydrogen': major
---

Add `cartGiftCardCodesAdd` mutation and remove duplicate filtering from `cartGiftCardCodesUpdate`

## New Feature: cartGiftCardCodesAdd

Adds gift card codes without replacing existing ones.

**Before (2025-07):**
```typescript
const codes = ['EXISTING1', 'EXISTING2'];
await cart.updateGiftCardCodes(['EXISTING1', 'EXISTING2', 'NEW_CODE']);
```

**After (2025-10):**
```typescript
await cart.addGiftCardCodes(['NEW_CODE']);
```

## Breaking Change: cartGiftCardCodesUpdate
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm waffling on this being considered a breaking change since it's so minimal but I guess it is a change in behaviour so we need to mark it as a break?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, Hyrum's law

Copy link
Contributor

@kdaviduik kdaviduik Jan 23, 2026

Choose a reason for hiding this comment

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

Okay I dug into this more - I was under the impression that Hydrogen's previous logic when calling this mutation was to support incrementally adding gift card codes, and now that we have a gift cards add mutation we want to use that to incrementally add gift cards and switch the update mutation back to being a "replace" mutation.

But that wasn't the case. Hydrogen was just filtering out duplicates before passing them to the SF API, but the SF API mutation is idempotent and ignores duplicates, so Hydrogen's logic is unnecessary and can be removed. This isn't coupled to the gift cards add mutation at all. So:
The function signature is unchanged, the return type is unchanged, and the observable outcome (which gift cards end up on the cart) is unchanged. The filtering was purely internal. If the API is idempotent for duplicates, consumers calling updateGiftCardCodes(['A', 'A', 'B']) get the same result as before.

Therefore this is NOT a breaking change and just an internal refactor with no side effects


Removed client-side duplicate code filtering. The Storefront API handles duplicates gracefully.

**What changed:**
- Previously: Hydrogen filtered duplicate codes before sending to API
- Now: Codes pass directly to API (thin wrapper pattern)

**Why:**
- API is idempotent for duplicate codes (verified via E2E testing)
- Consistent with all Add/Remove mutations (no filtering)
- Filtering was legacy code copied from discount codes

## Verified API Behavior (E2E tested 2026-01-21)

| Scenario | Behavior |
|----------|----------|
| Valid gift card code | Applied successfully |
| UPPERCASE code | Works (API is case-insensitive) |
| Duplicate code in same call | Idempotent - applied once, no error |
| Re-applying already applied code | Idempotent - no error, no duplicate |
| Multiple different codes | All applied successfully |
| Invalid code | Silently rejected (no error surfaced) |
| Code with whitespace | Rejected (API does not trim whitespace) |
| Empty input | Graceful no-op |

**Key finding:** The API handles duplicate gift card codes gracefully - submitting an already-applied code results in silent success (idempotent behavior), not an error. No `DUPLICATE_GIFT_CARD` error code exists.

## Migration

**Most users are unaffected.** The API handles duplicates gracefully.

If you rely on client-side deduplication for other reasons (e.g., avoiding unnecessary API calls), filter codes before calling:

```typescript
const uniqueCodes = [...new Set(codes)];
await cart.updateGiftCardCodes(uniqueCodes);
```

**Note on whitespace:** The API does NOT trim whitespace from codes. Ensure codes are trimmed before submission if accepting user input.

## API Reference

**New method:**
- `cart.addGiftCardCodes(codes)` - Appends codes to cart
- `CartForm.ACTIONS.GiftCardCodesAdd` - Form action

**Changed method:**
- `cart.updateGiftCardCodes(codes)` - No longer filters duplicates

## Skeleton Template Changes

The skeleton template has been updated to use the new `cartGiftCardCodesAdd` mutation:
- Removed `UpdateGiftCardForm` component from `CartSummary.tsx`
- Added `AddGiftCardForm` component using `CartForm.ACTIONS.GiftCardCodesAdd`

If you customized the gift card form in your project, you may want to migrate to the new `Add` action for simpler code.

## Usage

```typescript
import {CartForm} from '@shopify/hydrogen';

<CartForm action={CartForm.ACTIONS.GiftCardCodesAdd} inputs={{giftCardCodes: ['CODE1', 'CODE2']}}>
<button>Add Gift Cards</button>
</CartForm>
```

Or with createCartHandler:

```typescript
const cart = createCartHandler({storefront, getCartId, setCartId});
await cart.addGiftCardCodes(['SUMMER2025', 'WELCOME10']);
```
1 change: 1 addition & 0 deletions packages/hydrogen/src/cart/CartForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ describe('<CartForm />', () => {
DeliveryAddressesRemove: 'DeliveryAddressesRemove',
DiscountCodesUpdate: 'DiscountCodesUpdate',
GiftCardCodesUpdate: 'GiftCardCodesUpdate',
GiftCardCodesAdd: 'GiftCardCodesAdd',
GiftCardCodesRemove: 'GiftCardCodesRemove',
LinesAdd: 'LinesAdd',
LinesUpdate: 'LinesUpdate',
Expand Down
17 changes: 17 additions & 0 deletions packages/hydrogen/src/cart/CartForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ type CartGiftCardCodesUpdateRequire = {
} & OtherFormData;
};

type CartGiftCardCodesAddProps = {
action: 'GiftCardCodesAdd';
inputs?: {
giftCardCodes: string[];
} & OtherFormData;
};

type CartGiftCardCodesAddRequire = {
action: 'GiftCardCodesAdd';
inputs: {
giftCardCodes: string[];
} & OtherFormData;
};

type CartGiftCardCodesRemoveProps = {
action: 'GiftCardCodesRemove';
inputs?: {
Expand Down Expand Up @@ -278,6 +292,7 @@ type CartActionInputProps =
| CartCreateProps
| CartDiscountCodesUpdateProps
| CartGiftCardCodesUpdateProps
| CartGiftCardCodesAddProps
| CartGiftCardCodesRemoveProps
| CartLinesAddProps
| CartLinesUpdateProps
Expand All @@ -297,6 +312,7 @@ export type CartActionInput =
| CartCreateRequire
| CartDiscountCodesUpdateRequire
| CartGiftCardCodesUpdateRequire
| CartGiftCardCodesAddRequire
| CartGiftCardCodesRemoveRequire
| CartLinesAddRequire
| CartLinesUpdateRequire
Expand Down Expand Up @@ -345,6 +361,7 @@ CartForm.ACTIONS = {
Create: 'Create',
DiscountCodesUpdate: 'DiscountCodesUpdate',
GiftCardCodesUpdate: 'GiftCardCodesUpdate',
GiftCardCodesAdd: 'GiftCardCodesAdd',
GiftCardCodesRemove: 'GiftCardCodesRemove',
LinesAdd: 'LinesAdd',
LinesRemove: 'LinesRemove',
Expand Down
7 changes: 4 additions & 3 deletions packages/hydrogen/src/cart/createCartHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ describe('createCartHandler', () => {
const cart = getCartHandler();

expectTypeOf(cart).toEqualTypeOf<HydrogenCart>;
expect(Object.keys(cart)).toHaveLength(19);
expect(Object.keys(cart)).toHaveLength(20);
expect(cart).toHaveProperty('get');
expect(cart).toHaveProperty('getCartId');
expect(cart).toHaveProperty('setCartId');
Expand All @@ -47,6 +47,7 @@ describe('createCartHandler', () => {
expect(cart).toHaveProperty('removeLines');
expect(cart).toHaveProperty('updateDiscountCodes');
expect(cart).toHaveProperty('updateGiftCardCodes');
expect(cart).toHaveProperty('addGiftCardCodes');
expect(cart).toHaveProperty('removeGiftCardCodes');
expect(cart).toHaveProperty('updateBuyerIdentity');
expect(cart).toHaveProperty('updateNote');
Expand All @@ -72,7 +73,7 @@ describe('createCartHandler', () => {
});

expectTypeOf(cart).toEqualTypeOf<HydrogenCartCustom<{foo: () => 'bar'}>>;
expect(Object.keys(cart)).toHaveLength(20);
expect(Object.keys(cart)).toHaveLength(21);
expect(cart.foo()).toBe('bar');
});

Expand All @@ -86,7 +87,7 @@ describe('createCartHandler', () => {
});

expectTypeOf(cart).toEqualTypeOf<HydrogenCart>;
expect(Object.keys(cart)).toHaveLength(19);
expect(Object.keys(cart)).toHaveLength(20);
expect(await cart.get()).toBe('bar');
});

Expand Down
10 changes: 10 additions & 0 deletions packages/hydrogen/src/cart/createCartHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ import {
type CartGiftCardCodesUpdateFunction,
cartGiftCardCodesUpdateDefault,
} from './queries/cartGiftCardCodeUpdateDefault';
import {
type CartGiftCardCodesAddFunction,
cartGiftCardCodesAddDefault,
} from './queries/cartGiftCardCodesAddDefault';
import {
type CartGiftCardCodesRemoveFunction,
cartGiftCardCodesRemoveDefault,
Expand Down Expand Up @@ -94,6 +98,7 @@ export type HydrogenCart = {
removeLines: ReturnType<typeof cartLinesRemoveDefault>;
updateDiscountCodes: ReturnType<typeof cartDiscountCodesUpdateDefault>;
updateGiftCardCodes: ReturnType<typeof cartGiftCardCodesUpdateDefault>;
addGiftCardCodes: ReturnType<typeof cartGiftCardCodesAddDefault>;
removeGiftCardCodes: ReturnType<typeof cartGiftCardCodesRemoveDefault>;
updateBuyerIdentity: ReturnType<typeof cartBuyerIdentityUpdateDefault>;
updateNote: ReturnType<typeof cartNoteUpdateDefault>;
Expand Down Expand Up @@ -274,6 +279,7 @@ export function createCartHandler<TCustomMethods extends CustomMethodsBase>(
)
: await cartCreate({giftCardCodes}, optionalParams);
},
addGiftCardCodes: cartGiftCardCodesAddDefault(mutateOptions),
removeGiftCardCodes: cartGiftCardCodesRemoveDefault(mutateOptions),
updateBuyerIdentity: async (buyerIdentity, optionalParams) => {
return cartId || optionalParams?.cartId
Expand Down Expand Up @@ -425,6 +431,10 @@ export type HydrogenCartForDocs = {
* Updates gift card codes in the cart.
*/
updateGiftCardCodes?: CartGiftCardCodesUpdateFunction;
/**
* Adds gift card codes to the cart without replacing existing ones.
*/
addGiftCardCodes?: CartGiftCardCodesAddFunction;
/**
* Removes gift card codes from the cart.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,22 +16,30 @@ export type CartGiftCardCodesUpdateFunction = (
optionalParams?: CartOptionalInput,
) => Promise<CartQueryDataReturn>;

/**
* Updates (replaces) gift card codes in the cart.
*
* This function no longer filters duplicate codes internally.
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
* This function no longer filters duplicate codes internally.

* To add codes without replacing, use `cartGiftCardCodesAdd` (API 2025-10+).
*
* @param {CartQueryOptions} options - Cart query options including storefront client and cart fragment.
* @returns {CartGiftCardCodesUpdateFunction} - Function accepting gift card codes array and optional parameters.
*
* @example Replace all gift card codes
* const updateGiftCardCodes = cartGiftCardCodesUpdateDefault({ storefront, getCartId });
* await updateGiftCardCodes(['SUMMER2025', 'WELCOME10']);
*/
export function cartGiftCardCodesUpdateDefault(
options: CartQueryOptions,
): CartGiftCardCodesUpdateFunction {
return async (giftCardCodes, optionalParams) => {
// Ensure the gift card codes are unique
const uniqueCodes = giftCardCodes.filter((value, index, array) => {
return array.indexOf(value) === index;
});

const {cartGiftCardCodesUpdate, errors} = await options.storefront.mutate<{
cartGiftCardCodesUpdate: CartQueryData;
errors: StorefrontApiErrors;
}>(CART_GIFT_CARD_CODE_UPDATE_MUTATION(options.cartFragment), {
variables: {
cartId: options.getCartId(),
giftCardCodes: uniqueCodes,
giftCardCodes,
...optionalParams,
},
});
Expand Down
Copy link
Contributor

Choose a reason for hiding this comment

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

For my own learning: why do all of these have Default in the file name?

Copy link
Contributor

Choose a reason for hiding this comment

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

The 'Default' suffix indicates this is the default implementation. Custom implementations can be provided via createCartHandler options (eg: to use a custom cart fragment)

Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs';

const data: ReferenceEntityTemplateSchema = {
name: 'cartGiftCardCodesAddDefault',
category: 'utilities',
subCategory: 'cart',
isVisualComponent: false,
related: [
{
name: 'cartGiftCardCodesUpdateDefault',
type: 'utilities',
url: '/docs/api/hydrogen/utilities/cartgiftcardcodesupdatedefault',
},
{
name: 'cartGiftCardCodesRemoveDefault',
type: 'utilities',
url: '/docs/api/hydrogen/utilities/cartgiftcardcodesremovedefault',
},
{
name: 'createCartHandler',
type: 'utilities',
url: '/docs/api/hydrogen/utilities/createcarthandler',
},
],
description:
'Creates a function that adds gift card codes to a cart without replacing existing ones',
type: 'utility',
defaultExample: {
description: 'Add gift card codes to a cart using the default cart fragment',
codeblock: {
tabs: [
{
title: 'JavaScript',
code: './cartGiftCardCodesAddDefault.example.js',
language: 'js',
},
{
title: 'TypeScript',
code: './cartGiftCardCodesAddDefault.example.ts',
language: 'ts',
},
],
title: 'example',
},
},
definitions: [
{
title: 'cartGiftCardCodesAddDefault',
type: 'CartGiftCardCodesAddDefaultGeneratedType',
description: '',
},
],
};

export default data;
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import {cartGiftCardCodesAddDefault} from '@shopify/hydrogen';

export async function action({context}) {
const cartAddGiftCardCodes = cartGiftCardCodesAddDefault({
storefront: context.storefront,
getCartId: () => context.cart.getCartId(),
});

const result = await cartAddGiftCardCodes(['SUMMER2025', 'WELCOME10']);
return result;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {
cartGiftCardCodesAddDefault,
type HydrogenCart,
type CartQueryOptions,
} from '@shopify/hydrogen';

export async function action({context}: {context: CartQueryOptions}) {
const cartAddGiftCardCodes: HydrogenCart['addGiftCardCodes'] =
cartGiftCardCodesAddDefault({
storefront: context.storefront,
getCartId: context.getCartId,
});

const result = await cartAddGiftCardCodes(['SUMMER2025', 'WELCOME10']);
return result;
}
Loading
Loading