Skip to content

Commit 9951900

Browse files
authored
feat: cart fragment override type support (#3767)
Adds a TCart generic to createCartHandler and all cart query functions so the return type of every cart operation reflects the actual shape of the cart fragment being used.
1 parent a666c14 commit 9951900

30 files changed

Lines changed: 529 additions & 193 deletions
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@shopify/hydrogen': patch
3+
'skeleton': patch
4+
'@shopify/cli-hydrogen': patch
5+
'@shopify/create-hydrogen': patch
6+
---
7+
8+
Add generic cart result typing to `createCartHandler` so custom cart fragments can use their generated fragment types.

cookbook/recipes/custom-cart-method/patches/context.ts.c15041.patch

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
index 692d5ae17..c2dc8b338 100644
22
--- a/templates/skeleton/app/lib/context.ts
33
+++ b/templates/skeleton/app/lib/context.ts
4-
@@ -1,6 +1,15 @@
4+
@@ -1,7 +1,16 @@
55
-import {createHydrogenContext} from '@shopify/hydrogen';
66
+import {
77
+ createHydrogenContext,
@@ -12,18 +12,19 @@ index 692d5ae17..c2dc8b338 100644
1212
import {AppSession} from '~/lib/session';
1313
-import {CART_QUERY_FRAGMENT} from '~/lib/fragments';
1414
+import {CART_QUERY_FRAGMENT, PRODUCT_VARIANT_QUERY} from '~/lib/fragments';
15+
import type {CartApiQueryFragment} from 'storefrontapi.generated';
1516
+import type {
1617
+ SelectedOptionInput,
1718
+ CartLineUpdateInput,
1819
+} from '@shopify/hydrogen/storefront-api-types';
1920

2021
// Define the additional context object
2122
const additionalContext = {
22-
@@ -16,6 +25,15 @@ type AdditionalContextType = typeof additionalContext;
23-
24-
declare global {
25-
interface HydrogenAdditionalContext extends AdditionalContextType {}
26-
+
23+
@@ -21,6 +30,15 @@
24+
// Augment HydrogenCustomCartFragment with the codegen'd cart fragment type so
25+
// that context.cart.get() and all cart mutations return the extended cart type.
26+
interface HydrogenCustomCartFragment extends CartApiQueryFragment {}
27+
+
2728
+ // @description Augment the cart with custom methods for variant selection
2829
+ interface HydrogenCustomCartMethods {
2930
+ updateLineByOptions: (
@@ -35,7 +36,7 @@ index 692d5ae17..c2dc8b338 100644
3536
}
3637

3738
/**
38-
@@ -40,7 +58,8 @@ export async function createHydrogenRouterContext(
39+
@@ -45,7 +63,8 @@
3940
AppSession.init(request, [env.SESSION_SECRET]),
4041
]);
4142

@@ -45,7 +46,7 @@ index 692d5ae17..c2dc8b338 100644
4546
{
4647
env,
4748
request,
48-
@@ -51,6 +70,33 @@ export async function createHydrogenRouterContext(
49+
@@ -56,6 +75,33 @@
4950
i18n: {language: 'EN', country: 'US'},
5051
cart: {
5152
queryFragment: CART_QUERY_FRAGMENT,

cookbook/recipes/markets/patches/context.ts.c15041.patch

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
index 692d5ae17..7373ebca2 100644
22
--- a/templates/skeleton/app/lib/context.ts
33
+++ b/templates/skeleton/app/lib/context.ts
4-
@@ -1,6 +1,7 @@
5-
import {createHydrogenContext} from '@shopify/hydrogen';
4+
@@ -2,6 +2,7 @@
65
import {AppSession} from '~/lib/session';
76
import {CART_QUERY_FRAGMENT} from '~/lib/fragments';
7+
import type {CartApiQueryFragment} from 'storefrontapi.generated';
88
+import {getLocaleFromRequest} from './i18n';
99

1010
// Define the additional context object
1111
const additionalContext = {
12-
@@ -40,6 +41,8 @@ export async function createHydrogenRouterContext(
12+
@@ -45,6 +46,8 @@
1313
AppSession.init(request, [env.SESSION_SECRET]),
1414
]);
1515

@@ -18,7 +18,7 @@ index 692d5ae17..7373ebca2 100644
1818
const hydrogenContext = createHydrogenContext(
1919
{
2020
env,
21-
@@ -47,8 +50,7 @@ export async function createHydrogenRouterContext(
21+
@@ -52,8 +55,7 @@
2222
cache,
2323
waitUntil,
2424
session,

cookbook/recipes/third-party-api/patches/context.ts.c15041.patch

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
index 692d5ae17..0635384ad 100644
22
--- a/templates/skeleton/app/lib/context.ts
33
+++ b/templates/skeleton/app/lib/context.ts
4-
@@ -1,25 +1,10 @@
5-
import {createHydrogenContext} from '@shopify/hydrogen';
4+
@@ -2,29 +2,10 @@
65
import {AppSession} from '~/lib/session';
76
import {CART_QUERY_FRAGMENT} from '~/lib/fragments';
8-
-
7+
import type {CartApiQueryFragment} from 'storefrontapi.generated';
8+
+import {createRickAndMortyClient} from '~/lib/createRickAndMortyClient.server';
9+
910
-// Define the additional context object
1011
-const additionalContext = {
1112
- // Additional context for custom properties, CMS clients, 3P SDKs, etc.
@@ -20,16 +21,19 @@ index 692d5ae17..0635384ad 100644
2021
-
2122
-declare global {
2223
- interface HydrogenAdditionalContext extends AdditionalContextType {}
24+
-
25+
- // Augment HydrogenCustomCartFragment with the codegen'd cart fragment type so
26+
- // that context.cart.get() and all cart mutations return the extended cart type.
27+
- interface HydrogenCustomCartFragment extends CartApiQueryFragment {}
2328
-}
24-
+import {createRickAndMortyClient} from '~/lib/createRickAndMortyClient.server';
25-
29+
-
2630
/**
2731
- * Creates Hydrogen context for React Router 7.9.x
2832
+ * Creates Hydrogen context for React Router 7.9.x with third-party API support
2933
* Returns HydrogenRouterContextProvider with hybrid access patterns
3034
* */
3135
export async function createHydrogenRouterContext(
32-
@@ -40,6 +25,19 @@ export async function createHydrogenRouterContext(
36+
@@ -45,6 +26,19 @@
3337
AppSession.init(request, [env.SESSION_SECRET]),
3438
]);
3539

@@ -49,7 +53,7 @@ index 692d5ae17..0635384ad 100644
4953
const hydrogenContext = createHydrogenContext(
5054
{
5155
env,
52-
@@ -58,3 +56,12 @@ export async function createHydrogenRouterContext(
56+
@@ -63,3 +57,13 @@
5357

5458
return hydrogenContext;
5559
}
@@ -61,5 +65,5 @@ index 692d5ae17..0635384ad 100644
6165
+
6266
+declare global {
6367
+ interface HydrogenAdditionalContext extends AdditionalContextType {}
68+
+ interface HydrogenCustomCartFragment extends CartApiQueryFragment {}
6469
+}
65-
\ No newline at end of file

packages/cli/src/lib/setups/i18n/replacers.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ describe('i18n replacers', () => {
5959
"import { createHydrogenContext } from "@shopify/hydrogen";
6060
import { AppSession } from "~/lib/session";
6161
import { CART_QUERY_FRAGMENT } from "~/lib/fragments";
62+
import type { CartApiQueryFragment } from "storefrontapi.generated";
6263
import { getLocaleFromRequest } from "~/lib/i18n";
6364
6465
// Define the additional context object
@@ -75,6 +76,10 @@ describe('i18n replacers', () => {
7576
7677
declare global {
7778
interface HydrogenAdditionalContext extends AdditionalContextType {}
79+
80+
// Augment HydrogenCustomCartFragment with the codegen'd cart fragment type so
81+
// that context.cart.get() and all cart mutations return the extended cart type.
82+
interface HydrogenCustomCartFragment extends CartApiQueryFragment {}
7883
}
7984
8085
/**

packages/hydrogen/react-router.d.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,14 +30,24 @@ declare global {
3030
// updateLineByOptions: (productId: string, selectedOptions: SelectedOptionInput[], line: CartLineUpdateInput) => Promise<CartQueryDataReturn>;
3131
// }
3232
}
33+
34+
/**
35+
* The cart type used for `context.cart` in route files.
36+
*
37+
* `HydrogenCart`'s default type parameter already includes
38+
* `HydrogenCustomCartFragment & Cart`, so no explicit intersection is needed
39+
* here. The augmentable `HydrogenCustomCartFragment` interface is declared
40+
* in `cart/queries/cart-types.ts`.
41+
*/
42+
type HydrogenCartWithFragment = HydrogenCart & HydrogenCustomCartMethods;
3343
}
3444

3545
declare module 'react-router' {
3646
// Merge Hydrogen properties into React Router's context provider
3747
interface RouterContextProvider extends HydrogenAdditionalContext {
3848
// Standard Hydrogen context properties from HydrogenRouterContextProvider
3949
storefront: HydrogenRouterContextProvider['storefront'];
40-
cart: HydrogenCart & HydrogenCustomCartMethods;
50+
cart: HydrogenCartWithFragment;
4151
customerAccount: HydrogenRouterContextProvider['customerAccount'];
4252
env: HydrogenRouterContextProvider['env'];
4353
session: HydrogenRouterContextProvider['session'];
@@ -48,7 +58,7 @@ declare module 'react-router' {
4858
interface AppLoadContext extends HydrogenAdditionalContext {
4959
// Standard Hydrogen context properties from HydrogenRouterContextProvider
5060
storefront: HydrogenRouterContextProvider['storefront'];
51-
cart: HydrogenCart & HydrogenCustomCartMethods;
61+
cart: HydrogenCartWithFragment;
5262
customerAccount: HydrogenRouterContextProvider['customerAccount'];
5363
env: HydrogenRouterContextProvider['env'];
5464
session: HydrogenRouterContextProvider['session'];

packages/hydrogen/src/cart/createCartHandler.test.ts

Lines changed: 119 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
mockCreateCustomerAccountClient,
1010
mockCreateStorefrontClient,
1111
} from './cart-test-helper';
12-
import {Storefront} from '../storefront';
12+
import type {Storefront, StorefrontApiErrors} from '../storefront';
13+
import type {CartQueryDataReturn} from './queries/cart-types';
1314

1415
type MockCarthandler = {
1516
cartId?: string;
@@ -20,6 +21,17 @@ type MockCarthandler = {
2021
storefront?: Storefront;
2122
};
2223

24+
type CustomCart = {
25+
id: string;
26+
checkoutUrl: string;
27+
totalQuantity: number;
28+
customField?: string;
29+
};
30+
31+
type ExtraCartGetVariables = {
32+
customVariable?: string;
33+
};
34+
2335
function getCartHandler(options: MockCarthandler = {}) {
2436
const {cartId, ...rest} = options;
2537
return createCartHandler({
@@ -78,6 +90,73 @@ describe('createCartHandler', () => {
7890
expect(cart.foo()).toBe('bar');
7991
});
8092

93+
it('can type cart methods with a custom cart fragment type', () => {
94+
const cart = createCartHandler<CustomCart>({
95+
storefront: mockCreateStorefrontClient(),
96+
getCartId: () => undefined,
97+
setCartId: () => new Headers(),
98+
cartQueryFragment: 'cartQueryFragmentOverride',
99+
cartMutateFragment: 'cartMutateFragmentOverride',
100+
});
101+
102+
expectTypeOf(cart).toEqualTypeOf<HydrogenCart<CustomCart>>();
103+
expectTypeOf<Awaited<ReturnType<typeof cart.get>>>().toEqualTypeOf<
104+
(CustomCart & {errors?: StorefrontApiErrors}) | null
105+
>();
106+
expectTypeOf<Awaited<ReturnType<typeof cart.create>>>().toEqualTypeOf<
107+
CartQueryDataReturn<CustomCart>
108+
>();
109+
expectTypeOf<Awaited<ReturnType<typeof cart.addLines>>>().toEqualTypeOf<
110+
CartQueryDataReturn<CustomCart>
111+
>();
112+
});
113+
114+
it('can type custom cart get variables', () => {
115+
const cart = createCartHandler<CustomCart, ExtraCartGetVariables>({
116+
storefront: mockCreateStorefrontClient(),
117+
getCartId: () => undefined,
118+
setCartId: () => new Headers(),
119+
cartQueryFragment: 'cartQueryFragmentOverride',
120+
});
121+
122+
expectTypeOf<Parameters<typeof cart.get>[0]>().toMatchTypeOf<
123+
ExtraCartGetVariables | undefined
124+
>();
125+
126+
if (false) {
127+
void cart.get({customVariable: 'value'});
128+
// @ts-expect-error customVariable must be a string.
129+
void cart.get({customVariable: 123});
130+
}
131+
});
132+
133+
it('can combine custom cart typing with custom methods', () => {
134+
const cart = createCartHandler<
135+
CustomCart,
136+
ExtraCartGetVariables,
137+
{foo: () => 'bar'}
138+
>({
139+
storefront: mockCreateStorefrontClient(),
140+
getCartId: () => undefined,
141+
setCartId: () => new Headers(),
142+
cartQueryFragment: 'cartQueryFragmentOverride',
143+
cartMutateFragment: 'cartMutateFragmentOverride',
144+
customMethods: {
145+
foo() {
146+
return 'bar';
147+
},
148+
},
149+
});
150+
151+
expectTypeOf(cart).toEqualTypeOf<
152+
HydrogenCartCustom<{foo: () => 'bar'}, CustomCart, ExtraCartGetVariables>
153+
>();
154+
expectTypeOf<Awaited<ReturnType<typeof cart.get>>>().toEqualTypeOf<
155+
(CustomCart & {errors?: StorefrontApiErrors}) | null
156+
>();
157+
expect(cart.foo()).toBe('bar');
158+
});
159+
81160
it('can override default methods', async () => {
82161
const cart = getCartHandler({
83162
customMethods: {
@@ -322,6 +401,45 @@ describe('createCartHandler', () => {
322401
expect(result.cart).toHaveProperty('id', 'c1-new-cart-id');
323402
});
324403

404+
it('function updateBuyerIdentity returns cart create errors when cart is not created', async () => {
405+
const storefront = {
406+
...mockCreateStorefrontClient(),
407+
mutate: vi.fn().mockResolvedValueOnce({
408+
cartCreate: {
409+
cart: null,
410+
userErrors: [{message: 'Cannot create cart'}],
411+
},
412+
}),
413+
} as unknown as Storefront;
414+
const cart = getCartHandler({storefront});
415+
416+
const result = await cart.updateBuyerIdentity({
417+
companyLocationId: 'gid://shopify/CompanyLocation/1',
418+
});
419+
420+
expect(result.cart).toBeNull();
421+
expect(result.userErrors?.[0]).toHaveProperty(
422+
'message',
423+
'Cannot create cart',
424+
);
425+
});
426+
427+
it('function create throws when a returned cart is missing id', async () => {
428+
const storefront = {
429+
...mockCreateStorefrontClient(),
430+
mutate: vi.fn().mockResolvedValueOnce({
431+
cartCreate: {
432+
cart: {totalQuantity: 0},
433+
},
434+
}),
435+
} as unknown as Storefront;
436+
const cart = getCartHandler({storefront});
437+
438+
await expect(cart.create({})).rejects.toThrow(
439+
'Cart created but response is missing a valid `id` field.',
440+
);
441+
});
442+
325443
it('function updateNote has a working default implementation', async () => {
326444
const cart = getCartHandler({cartId: 'c1-123'});
327445

0 commit comments

Comments
 (0)