feat: cart fragment override type support#3767
Conversation
|
Oxygen deployed a preview of your
Learn more about Hydrogen's GitHub integration. |
theocerutti
left a comment
There was a problem hiding this comment.
Im glad Hydrogen team did that! It looks good! Can't wait to be merged :)
frandiox
left a comment
There was a problem hiding this comment.
Thanks! I left some comments but I think I misunderstood how the current Hydrogen works.
I was expecting Hydrogen to return the type of the internal cart fragment... but it seems we were returning the whole cart type, probably to account for custom cart fragments... 😬
In an ideal scenario, Hydrogen would return only the types for the internal cart fragment and let that interface be augmented in user-land by the generated type for the custom cart fragment.
However, reducing the current returned cart types to only the internal default fragment when no custom fragment is passed would be a breaking change in types I think, so perhaps the current PR is the best fit for now 🤔
|
@andguy95 oh and maybe we should be able to pass a custom cart for Analytics.Provider ? and same for all the could be another done in another PR however, it's less important |
|
@theocerutti That is a good call! I'll fast follow with another to add that support, so we can keep this PR scope to the cart handler :) |
fredericoo
left a comment
There was a problem hiding this comment.
code works, but there are a few type/logic assertions i wish we could improve on
| @@ -0,0 +1,5 @@ | |||
| --- | |||
| '@shopify/hydrogen': patch | |||
There was a problem hiding this comment.
skeleton was changed as well!
| '@shopify/hydrogen': patch | |
| '@shopify/hydrogen': patch | |
| 'skeleton': patch | |
| '@shopify/cli-hydrogen': patch | |
| '@shopify/create-hydrogen': patch |
| options: CartHandlerOptionsWithRequiredCustom<TCustomMethods>, | ||
| ): HydrogenCartCustom<TCustomMethods>; | ||
| export function createCartHandler<TCustomMethods extends CustomMethodsBase>( | ||
| export function createCartHandler<TCart = Cart, TGetExtraVariables = {}>( |
There was a problem hiding this comment.
blocking: createCartHandler() defaults TCart to Cart, so the source-level HydrogenCustomCartFragment augmentation does not flow through the direct cart handler API.
HydrogenCart itself defaults to HydrogenCustomCartFragment & Cart, but this overload overrides that with TCart = Cart:
export function createCartHandler<TCart = Cart, TGetExtraVariables = {}>(
options: CartHandlerOptions,
): HydrogenCart<TCart, TGetExtraVariables>;That means a merchant can augment HydrogenCustomCartFragment, call createCartHandler({...}) directly, and still get HydrogenCart<Cart> unless they pass the generic manually. That contradicts the source-level augmentation described in cart-types.ts and the PR description.
Let's default the overload and implementation to HydrogenCustomCartFragment & Cart too, or remove the explicit Cart default so HydrogenCart's default is the single source of truth.
| expectTypeOf<CartWithExplicitDefault>().toHaveProperty('addLines'); | ||
| }); | ||
|
|
||
| it('should allow augmentation to add custom fields to cart return types', () => { |
There was a problem hiding this comment.
blocking: this test can pass even if the new global augmentation path is broken.
The test manually passes CustomFragment into HydrogenCart<CustomFragment>, so it only proves the explicit generic path works:
type CustomCart = HydrogenCart<CustomFragment>;The feature this PR is adding is that merchants augment HydrogenCustomCartFragment once and then context.cart / createCartHandler pick it up without passing a generic everywhere. Let's add a type test that actually declares a global HydrogenCustomCartFragment augmentation and asserts the default cart type sees the custom field.
This probably wants to live in an isolated type fixture/file so the augmentation does not pollute the existing empty-default assertions in this test file.
This is blocking because the new test would still pass if the behaviour we are trying to protect regressed.
|
|
||
| if (isCustomerLoggedIn && cart?.checkoutUrl) { | ||
| const finalCheckoutUrl = new URL(cart.checkoutUrl); | ||
| const cartWithCheckoutUrl = cart as |
There was a problem hiding this comment.
blocking: let's not add as assertions in the new generic cart path.
cart is typed as TCart | null, then asserted into something with checkoutUrl so we can mutate the logged-in checkout URL:
const cartWithCheckoutUrl = cart as
| (TCart & {checkoutUrl?: string})
| null
| undefined;That bypasses the type system exactly where this PR is trying to make cart typing safer. Let's constrain the generic return type to the cart fields the handler actually needs, or use a small type guard before reading/mutating checkoutUrl.
|
|
||
| const result = await _cartCreate(...args); | ||
| cartId = result?.cart?.id; | ||
| cartId = (result?.cart as {id?: string} | undefined)?.id; |
There was a problem hiding this comment.
blocking: same issue here - TCart is unconstrained, but the handler still requires cart.id to persist the cart ID after cartCreate.
can you try this:
if (!result?.cart || 'id' in result.cart === false || typeof result.cart.id !== 'string') throw new Error("Cart needs an id");
cartId = result.cart.id;If the generic cart type does not include id, TypeScript should force us to model that contract instead of letting the handler silently lose the cart ID.
Ideally also constrain TCart to include the required base cart fields, but throwing if missing a required base part is acceptable
| optionalParams, | ||
| ) | ||
| : await cartCreate({metafields}, optionalParams); | ||
| : ((await cartCreate({metafields}, optionalParams)) as Awaited< |
There was a problem hiding this comment.
blocking: this cast hides a return-type mismatch in the setMetafields fallback path.
When there is no cart ID, setMetafields creates a cart and then casts the cartCreate() result to CartMetafieldsSetFunction's return type:
: ((await cartCreate({metafields}, optionalParams)) as Awaited<
ReturnType<CartMetafieldsSetFunction>
>);That makes the branch compile, but it also means this method is not honestly typed through the new TCart path. Let's model this return type directly instead of asserting it away, even if the answer is that setMetafields intentionally stays on the existing non-generic metafield return shape.


WHY are these changes introduced?
Merchants who customize their cart GraphQL fragment (via
cartQueryFragment/cartMutateFragment) couldn't get TypeScript to recognize the extra fields they added. They'd fetch metafields, custom attributes, or other additional data at runtime, but the return type was always the baseCart— so accessing those fields gave a TypeScript error even though the data was there. Attempts to address #2440WHAT is this pull request doing?
Adds a
TCartgeneric tocreateCartHandlerand all cart query functions so the return type of every cart operation reflects the actual shape of the cart fragment being used.Also introduces a new
HydrogenCustomCartFragmentaugmentable global interface (inreact-router.d.ts) that threads the type through tocontext.cartin route files without requiring any changes to howcreateHydrogenContextis called. When empty (the default), behaviour is identical to before. When extended with a codegen'd fragment type, all cart methods automatically return the extended type.Example of what a dev might do
Here's that section rewritten with an inline copyable example:
1. Add the field to your cart fragment in
app/lib/fragments.ts:2. Run codegen to regenerate your types:
3. Augment
HydrogenCustomCartFragmentinapp/lib/context.ts:All cart methods in every route file now reflect the extended type:
HOW to test your changes?
app/lib/fragments.ts, add a field toCART_QUERY_FRAGMENTthat doesn't exist on the baseCarttype (e.g.foobar: metafields)pnpm run codegen— the generatedCartApiQueryFragmentinstorefrontapi.generated.d.tswill include the new fieldapp/lib/context.ts, add inside the existingdeclare globalblock:context.cart.get()and verify TypeScript now autocompletes and accepts the new fieldTo verify no regression for existing users: skip step 4 entirely and confirm
context.cartstill types correctly with the baseCartfields.Checklist