feat(cart): add server-only cart metafield intents to /api/cart - #3942
feat(cart): add server-only cart metafield intents to /api/cart#3942andguy95 wants to merge 4 commits into
Conversation
| variables: { metafields: metafields.map((metafield) => ({ ...metafield, ownerId: cartId })) }, | ||
| }); | ||
| const { userErrors } = assertMutationData(result, "cartMetafieldsSet"); | ||
| return refetchCartAfterMetafieldMutation(cartId, userErrors, storefront, queries); |
There was a problem hiding this comment.
Refetching cart after mutation because metafield mutations dont return cart
There was a problem hiding this comment.
Do we need the whole cart back from that mutation? Maybe that's required by our contract or something else?
| } | ||
|
|
||
| // Intents that fall back to cartCreate when no cart exists yet. | ||
| const CART_CREATING_INTENTS: ReadonlySet<CartAction["intent"]> = new Set(["add", "metafields-set"]); |
There was a problem hiding this comment.
Copied over parity where metafield set can create a cart if one doesn't exist.
Was debating if this was needed. But then figured this would make sense if storefronts have custom product budle meta, or quiz preference meta before the added a product that needs to get attached to cart -> order first.
frandiox
left a comment
There was a problem hiding this comment.
Thanks for tackling this! I've left a few comments below but this also makes me think about bigger directions:
JSON API
Thinking that if we ever land this in Standard Actions, we might want to have a consistent API with that we have already there. What would you think about this instead?
updateCart({
note: "",
discountCodes: ["SAVE10"],
metafields: {
set: [
{
key: "custom.instructions",
type: "single_line_text_field",
value: "Back door",
},
],
delete: ["custom.old_value"],
},
});So basically in Hydrogen's server-only support for metafields we'd do:
// Set metafields
{metafields: {set: [{...}]}}
// Delete metafields
{metafields: {delete: ["..."]}}
I know the delete mutation is only for 1 at a time, but it's actually possible to add multiple mutations in the same request by using graphql aliases... so perhaps the array is a good feature to keep it consistent with other fields?
Local state
I see this relies on useState etc. for metafields, which makes sense since it's custom data at this point. I was thinking that perhaps we could offer a place for custom data within the cart store itself? It could be an "easy" win because it would work in every framework.
The main thing we need is:
- A place to store it: maybe under
store.data.custom = {...}or similar. - A way to update it: "official" data relies on standard events. This could have its own
const {setCustomData} = useCart()function or similar? Just as an example.
Would this make sense at all?
| CartAction, | ||
| CartLineAddInput, | ||
| CartLineUpdateInput, | ||
| CartMetafieldInput, |
There was a problem hiding this comment.
Should we also export CartMetafieldInput from core/index.ts? Or not needed?
There was a problem hiding this comment.
Good catch. I’ve exported CartMetafieldInput now
| storefront: CartMutationClient, | ||
| queries: RuntimeCartQueries, | ||
| ): Promise<MutationResult> { | ||
| const result = await storefront.graphql(queries.cart, { variables: { id: cartId } }); |
There was a problem hiding this comment.
The metafield write can succeed, but the cart query after it can still fail. In that case, the API rejects the whole request and the UI says "Network error" even though the metafield was saved. Not sure what would be the best action at that point... perhaps return partial data? But that begs the question... do we need to return the whole cart (which I already asked in another comment lol).
There was a problem hiding this comment.
This is a good catch, didn't even cross my mind to think the second call might fail 🥴.
We don’t have to return the cart. I added the refetch to keep metafield mutation results consistent with the existing cart handlers.
Not refetching better matches SFAPI behavior and avoids an additional requests/failure cases.
Keeping it would be an intentional developer-ergonomics choice with a consistent response shape at the cost of that extra request. Curious what @fredericoo thinks!
There was a problem hiding this comment.
Alternatively, we could leave metafields out of the handled API and just force users to write their own logic? No sure how often are these used tbh
There was a problem hiding this comment.
the more we talk about this feature, the more i think this (cart metafields) can be done post GA or even as a skill only
the reason why is that developers can still write it themselves without any of this with not a lot of boilerplate. What i would do with this task is ensure:
- we are able to pass other selected fields into the cart store (i think we already are)
- we can tap into the
POSTto add an intent ourselves (using a custom route with the cart server handler, but branching off if the intent is different) - alternatively we can revalidate the cart store manually. E.g.: a nextjs server function gets called to set the metafield, once done we revalidate the cart (client side request)
if/when those 3 are possible, i'd even go as far as saying it's a good example to show how to extend our own APIs. then we ship zero metafield code that caters to very specific edge cases
sorry if this does not directly answer the question, its just from what we've discussed yesterday i get this feeling
maybe we can gauge how many customers use this, and try to see if we can whip it easily with just skills first?
There was a problem hiding this comment.
we can tap into the POST to add an intent ourselves (using a custom route with the cart server handler, but branching off if the intent is different)
Not sure if we should open up the "intents". It's something that might change perhaps if we eventually support multi-action POSTs? (e.g. updating note + lines at the same time, like SA do).
|
|
||
| import { useCart } from "~/lib/cart"; | ||
|
|
||
| // Cart metafields are server-only: the Storefront cart ajax API (Standard |
There was a problem hiding this comment.
Theoretically... we could still add them to the cart store even if they are not part of standard actions, right?
And wouldn't it be automatic if they add a custom cart fragment with metafields?
I guess the problem is that they can't be updated without standard events after mutations?
| sellingPlanId?: string; | ||
| }; | ||
|
|
||
| export type CartMetafieldInput = { |
There was a problem hiding this comment.
Could this type reuse the generated CartInputMetafieldInput?
There was a problem hiding this comment.
Yep. CartMetafieldInput now aliases the generated CartInputMetafieldInput.
| } | ||
|
|
||
| // Intents that fall back to cartCreate when no cart exists yet. | ||
| const CART_CREATING_INTENTS: ReadonlySet<CartAction["intent"]> = new Set(["add", "metafields-set"]); |
| variables: { metafields: metafields.map((metafield) => ({ ...metafield, ownerId: cartId })) }, | ||
| }); | ||
| const { userErrors } = assertMutationData(result, "cartMetafieldsSet"); | ||
| return refetchCartAfterMetafieldMutation(cartId, userErrors, storefront, queries); |
There was a problem hiding this comment.
Do we need the whole cart back from that mutation? Maybe that's required by our contract or something else?
The PR adds new public API surface (metafields-set / metafield-delete intents and the CartMetafieldInput type), which is additive for consumers and therefore a minor bump for @Shopify/hydrogen.
6fdfd8d to
2cff079
Compare
TL;DR: The preview cart API had no way to set or delete cart metafields. Standard Actions (cart ajax) doesn't support metafields, so this adds server-only
metafields-setandmetafield-deleteintents to/api/cart, plus an example that uses them for buyer delivery instructions that copy onto the order.Before
POST /api/cartonly understood line, discount, and note operations. Setting a cart metafield required writing a custom route with raw Storefront API mutations, including the awkward part:cartMetafieldsSetandcartMetafieldDeleteare the only cart mutations whose responses contain no cart object, onlyuserErrors.After
Forms are supported too, one metafield per submission:
intent=metafields-setwithmetafieldKey/metafieldType/metafieldValuefields, andintent=metafield-deletewithmetafieldKey.What this changes
actions.ts: parses the two new intents from JSON and FormData. The parser rebuilds each metafield as exactly{key, type, value}— a client can't smuggle in anownerId; the server injects it from the resolved cart id.queries.ts: adds the two mutations as standalone constants. Their responses have no cart object, so no cart fragment applies and they live outsidemakeCartQueries, preserving the invariant that everycartQueriesdocument spreads the cart fragments.server-handlers.ts: after mutating, the handler refetches the cart withqueries.cartso every intent returns the same uniform result shape — and the refetch carries the user's customCartFragment, so the response includes the metafields just written.metafields-setwithout an existing cart falls back tocartCreate({ metafields }), matching old Hydrogen.CartDeliveryInstructionscomponent in the cart summary. It posts JSON withfetch(no navigation) and renders the saved value from the POST response.cart-handlers.tsnow passes aCartFragmentselecting the metafield, demonstrating typed read-back end to end.Developer impact
CartMetafieldInput. TheCartActionunion gains two members.@shopify/hydrogen(additive API surface: new intents and a new exported type).UX impact
Example only: the cart page and aside get a "Delivery instructions" textarea with save/remove buttons, a disabled-while-saving state, and a
role="status"message for saved/error feedback.Out of scope
Risk
How to Test
pnpm install && pnpm --filter @shopify/hydrogen buildto rebuild the package.examples/hydrogen/.env) pointing at a store, and start the example hydrogen dev server.http://localhost:5173, add a product to the cart, and open/cart.custom.delivery_instructionswith the cart-to-order copyable capability enabled. Place a test order and check the order's metafields in admin.