From d3a47246d7aa15a2d9a803e7dbef1ae5729c06c4 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 28 May 2026 10:46:35 -0500 Subject: [PATCH 1/6] resync order shipping of shipping lines and lineItems fulfillment NONE --- .changeset/sync-shipping-fulfillment.md | 5 +++ .../checkout/shipping/shipping-method.tsx | 43 +++++++++++++++++-- 2 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 .changeset/sync-shipping-fulfillment.md diff --git a/.changeset/sync-shipping-fulfillment.md b/.changeset/sync-shipping-fulfillment.md new file mode 100644 index 00000000..4ddb0961 --- /dev/null +++ b/.changeset/sync-shipping-fulfillment.md @@ -0,0 +1,5 @@ +--- +"@godaddy/react": patch +--- + +Re-apply shipping methods when checkout line items change after shipping has already been selected. diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 8a698c94..46033965 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -63,6 +63,13 @@ export function ShippingMethodForm() { lineItem => lineItem.fulfillmentMode === DeliveryMethods.PICKUP ) ); + const hasLineItemsMissingShippingFulfillment = Boolean( + order?.lineItems?.some( + lineItem => + !lineItem.fulfillmentMode || + lineItem.fulfillmentMode === DeliveryMethods.NONE + ) + ); const orderSubTotal = totals?.subTotal?.value || 0; @@ -81,19 +88,31 @@ export function ShippingMethodForm() { hadShippingMethods: boolean; wasPickup: boolean; clearedShippingMethod: boolean; + syncingFulfillmentKey: string | null; }>({ serviceCode: null, cost: null, hadShippingMethods: false, wasPickup: false, clearedShippingMethod: false, + syncingFulfillmentKey: null, }); useEffect(() => { - if (isShippingMethodsLoading || isDraftOrderLoading || isConfirmingCheckout) + if ( + isShippingMethodsLoading || + isDraftOrderLoading || + isConfirmingCheckout || + applyShippingMethod.isPending + ) return; const hasShippingMethods = (shippingMethods?.length ?? 0) > 0; + const fulfillmentSyncKey = hasLineItemsMissingShippingFulfillment + ? order?.lineItems + ?.map(lineItem => `${lineItem.id}:${lineItem.fulfillmentMode ?? ''}`) + .join('|') || null + : null; const currentServiceCode = shippingLines?.requestedService || null; const currentCost = shippingLines?.amount?.value ?? null; const lastState = lastProcessedStateRef.current; @@ -116,6 +135,7 @@ export function ShippingMethodForm() { hadShippingMethods: false, wasPickup: isPickup, clearedShippingMethod: true, + syncingFulfillmentKey: null, }; } return; @@ -143,10 +163,19 @@ export function ShippingMethodForm() { const methodToApply = matchedMethod || firstMethod; const methodCost = methodToApply.cost?.value ?? null; - // Check if we've already processed this exact state + // Check if we've already processed this exact state. If cart contents + // changed after a shipping method was selected, shippingLines can still + // match the selected rate while new line items are NONE. In that case we + // must re-apply the method so checkout-api recalculates shipping and sets + // line item fulfillmentMode to SHIP. + const isAlreadySyncingFulfillment = + Boolean(fulfillmentSyncKey) && + fulfillmentSyncKey === lastState.syncingFulfillmentKey; const alreadyProcessed = methodToApply.serviceCode === lastState.serviceCode && - methodCost === lastState.cost; + methodCost === lastState.cost && + (!hasLineItemsMissingShippingFulfillment || + isAlreadySyncingFulfillment); if (!alreadyProcessed) { form.setValue('shippingMethod', methodToApply.serviceCode, { @@ -156,7 +185,9 @@ export function ShippingMethodForm() { // Only mutate if the method or cost actually changed on the order const needsMutation = methodToApply.serviceCode !== currentServiceCode || - methodCost !== currentCost; + methodCost !== currentCost || + (hasLineItemsMissingShippingFulfillment && + !isAlreadySyncingFulfillment); if (needsMutation) { applyShippingMethod.mutate(buildShippingPayload(methodToApply)); @@ -170,6 +201,7 @@ export function ShippingMethodForm() { hadShippingMethods: true, wasPickup: false, clearedShippingMethod: false, + syncingFulfillmentKey: needsMutation ? fulfillmentSyncKey : null, }; } } @@ -185,6 +217,9 @@ export function ShippingMethodForm() { session?.enableTaxCollection, isPickup, isDraftOrderLoading, + hasLineItemsMissingShippingFulfillment, + applyShippingMethod.isPending, + order?.lineItems, ]); if (isShippingMethodsLoading || isShippingAddressLoading) { From efcd796732e7734dc3b223581ed181815f9c4b45 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 28 May 2026 12:01:53 -0500 Subject: [PATCH 2/6] fix feedback from PR and add tests --- .../checkout/shipping/shipping-method.tsx | 96 +++++--- .../should-apply-shipping-method.test.ts | 223 ++++++++++++++++++ .../utils/should-apply-shipping-method.ts | 70 ++++++ 3 files changed, 354 insertions(+), 35 deletions(-) create mode 100644 packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts create mode 100644 packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 46033965..877788de 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -1,3 +1,4 @@ +import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; @@ -12,6 +13,10 @@ import { useUpdateTaxes } from '@/components/checkout/order/use-update-taxes'; import { useIsPaymentDisabled } from '@/components/checkout/payment/utils/use-is-payment-disabled'; import { ShippingMethodSkeleton } from '@/components/checkout/shipping/shipping-method-skeleton'; import { filterAndSortShippingMethods } from '@/components/checkout/shipping/utils/filter-shipping-methods'; +import { + getShippingFulfillmentSyncKey, + shouldApplyShippingMethod, +} from '@/components/checkout/shipping/utils/should-apply-shipping-method'; import { useApplyShippingMethod } from '@/components/checkout/shipping/utils/use-apply-shipping-method'; import { useDraftOrderShippingMethods } from '@/components/checkout/shipping/utils/use-draft-order-shipping-methods'; import { useFormatCurrency } from '@/components/checkout/utils/format-currency'; @@ -47,6 +52,7 @@ export function ShippingMethodForm() { const { t } = useGoDaddyContext(); const { session, isConfirmingCheckout } = useCheckoutContext(); const updateTaxes = useUpdateTaxes(); + const queryClient = useQueryClient(); const isPaymentDisabled = useIsPaymentDisabled(); const { data: shippingMethodsData, isLoading: isShippingMethodsLoading } = @@ -63,13 +69,8 @@ export function ShippingMethodForm() { lineItem => lineItem.fulfillmentMode === DeliveryMethods.PICKUP ) ); - const hasLineItemsMissingShippingFulfillment = Boolean( - order?.lineItems?.some( - lineItem => - !lineItem.fulfillmentMode || - lineItem.fulfillmentMode === DeliveryMethods.NONE - ) - ); + const fulfillmentSyncKey = getShippingFulfillmentSyncKey(order?.lineItems); + const hasLineItemsMissingShippingFulfillment = Boolean(fulfillmentSyncKey); const orderSubTotal = totals?.subTotal?.value || 0; @@ -88,14 +89,14 @@ export function ShippingMethodForm() { hadShippingMethods: boolean; wasPickup: boolean; clearedShippingMethod: boolean; - syncingFulfillmentKey: string | null; + inFlightFulfillmentKey: string | null; }>({ serviceCode: null, cost: null, hadShippingMethods: false, wasPickup: false, clearedShippingMethod: false, - syncingFulfillmentKey: null, + inFlightFulfillmentKey: null, }); useEffect(() => { @@ -108,15 +109,19 @@ export function ShippingMethodForm() { return; const hasShippingMethods = (shippingMethods?.length ?? 0) > 0; - const fulfillmentSyncKey = hasLineItemsMissingShippingFulfillment - ? order?.lineItems - ?.map(lineItem => `${lineItem.id}:${lineItem.fulfillmentMode ?? ''}`) - .join('|') || null - : null; const currentServiceCode = shippingLines?.requestedService || null; - const currentCost = shippingLines?.amount?.value ?? null; const lastState = lastProcessedStateRef.current; + if ( + !hasLineItemsMissingShippingFulfillment && + lastState.inFlightFulfillmentKey + ) { + lastProcessedStateRef.current = { + ...lastState, + inFlightFulfillmentKey: null, + }; + } + // Case 1: No shipping methods available - clear shipping and set fulfillment to SHIP if (!hasShippingMethods && hasShippingAddress) { // Apply empty shipping method if: @@ -135,7 +140,7 @@ export function ShippingMethodForm() { hadShippingMethods: false, wasPickup: isPickup, clearedShippingMethod: true, - syncingFulfillmentKey: null, + inFlightFulfillmentKey: null, }; } return; @@ -161,36 +166,53 @@ export function ShippingMethodForm() { : null; const methodToApply = matchedMethod || firstMethod; - const methodCost = methodToApply.cost?.value ?? null; - // Check if we've already processed this exact state. If cart contents // changed after a shipping method was selected, shippingLines can still // match the selected rate while new line items are NONE. In that case we // must re-apply the method so checkout-api recalculates shipping and sets // line item fulfillmentMode to SHIP. - const isAlreadySyncingFulfillment = - Boolean(fulfillmentSyncKey) && - fulfillmentSyncKey === lastState.syncingFulfillmentKey; - const alreadyProcessed = - methodToApply.serviceCode === lastState.serviceCode && - methodCost === lastState.cost && - (!hasLineItemsMissingShippingFulfillment || - isAlreadySyncingFulfillment); + const { alreadyProcessed, needsMutation, methodCost } = + shouldApplyShippingMethod({ + methodToApply, + shippingLine: shippingLines, + lastState, + fulfillmentSyncKey, + }); if (!alreadyProcessed) { form.setValue('shippingMethod', methodToApply.serviceCode, { shouldDirty: false, }); - // Only mutate if the method or cost actually changed on the order - const needsMutation = - methodToApply.serviceCode !== currentServiceCode || - methodCost !== currentCost || - (hasLineItemsMissingShippingFulfillment && - !isAlreadySyncingFulfillment); - if (needsMutation) { - applyShippingMethod.mutate(buildShippingPayload(methodToApply)); + const isFulfillmentSync = Boolean( + hasLineItemsMissingShippingFulfillment && fulfillmentSyncKey + ); + + if (isFulfillmentSync) { + lastProcessedStateRef.current = { + ...lastProcessedStateRef.current, + inFlightFulfillmentKey: fulfillmentSyncKey, + }; + } + + applyShippingMethod.mutate(buildShippingPayload(methodToApply), { + onSuccess: () => { + if (!isFulfillmentSync || !session?.id) return; + + queryClient.invalidateQueries({ + queryKey: ['draft-order', session.id], + }); + }, + onError: () => { + if (!isFulfillmentSync) return; + + lastProcessedStateRef.current = { + ...lastProcessedStateRef.current, + inFlightFulfillmentKey: null, + }; + }, + }); } else if (session?.enableTaxCollection) { updateTaxes.mutate(undefined); } @@ -201,7 +223,9 @@ export function ShippingMethodForm() { hadShippingMethods: true, wasPickup: false, clearedShippingMethod: false, - syncingFulfillmentKey: needsMutation ? fulfillmentSyncKey : null, + inFlightFulfillmentKey: needsMutation + ? lastProcessedStateRef.current.inFlightFulfillmentKey + : null, }; } } @@ -215,6 +239,8 @@ export function ShippingMethodForm() { applyShippingMethod, updateTaxes.mutate, session?.enableTaxCollection, + queryClient, + session?.id, isPickup, isDraftOrderLoading, hasLineItemsMissingShippingFulfillment, diff --git a/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts new file mode 100644 index 00000000..480e84b7 --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts @@ -0,0 +1,223 @@ +import { describe, expect, it } from 'vitest'; +import type { DraftOrder, ShippingLines, ShippingMethod } from '@/types'; +import { + getShippingFulfillmentSyncKey, + shouldApplyShippingMethod, +} from './should-apply-shipping-method'; + +const shippingMethod = { + serviceCode: 'ground', + cost: { + value: 100, + currencyCode: 'USD', + }, +} as ShippingMethod; + +const shippingLine = { + requestedService: 'ground', + amount: { + value: 100, + }, +} as ShippingLines; + +const processedState = { + serviceCode: 'ground', + cost: 100, + inFlightFulfillmentKey: null, +}; + +describe('getShippingFulfillmentSyncKey', () => { + it('returns a sync key when a line item has NONE fulfillment', () => { + const key = getShippingFulfillmentSyncKey([ + { + id: 'line-item-1', + fulfillmentMode: 'SHIP', + }, + { + id: 'line-item-2', + fulfillmentMode: 'NONE', + }, + ] as DraftOrder['lineItems']); + + expect(key).toBe('line-item-1:SHIP|line-item-2:NONE'); + }); + + it('returns a sync key when a line item has null fulfillment', () => { + const key = getShippingFulfillmentSyncKey([ + { + id: 'line-item-1', + fulfillmentMode: null, + }, + ] as DraftOrder['lineItems']); + + expect(key).toBe('line-item-1:'); + }); + + it('returns null when all line items are already shipping fulfilled', () => { + const key = getShippingFulfillmentSyncKey([ + { + id: 'line-item-1', + fulfillmentMode: 'SHIP', + }, + ] as DraftOrder['lineItems']); + + expect(key).toBeNull(); + }); +}); + +describe('shouldApplyShippingMethod', () => { + it('applies when the shipping line matches but line items are missing SHIP fulfillment', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: 'line-item-1:NONE', + lastState: processedState, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('does not repeatedly apply while the same fulfillment sync is in flight', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: 'line-item-1:NONE', + lastState: { + ...processedState, + inFlightFulfillmentKey: 'line-item-1:NONE', + }, + }); + + expect(result.needsMutation).toBe(false); + expect(result.alreadyProcessed).toBe(true); + expect(result.isFulfillmentSyncInFlight).toBe(true); + }); + + it('allows retrying after the in-flight fulfillment key is cleared', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: 'line-item-1:NONE', + lastState: processedState, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('applies when a later cart edit creates a different fulfillment sync key', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: 'line-item-2:NONE', + lastState: { + ...processedState, + inFlightFulfillmentKey: 'line-item-1:NONE', + }, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + expect(result.isFulfillmentSyncInFlight).toBe(false); + }); + + it('does not apply when shipping line and method match and line items are fulfilled', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: null, + lastState: processedState, + }); + + expect(result.needsMutation).toBe(false); + expect(result.alreadyProcessed).toBe(true); + }); + + it('applies when the selected shipping method changed', () => { + const result = shouldApplyShippingMethod({ + methodToApply: { + ...shippingMethod, + serviceCode: 'express', + } as ShippingMethod, + shippingLine, + fulfillmentSyncKey: null, + lastState: processedState, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('applies when the existing shipping line service code differs', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine: { + ...shippingLine, + requestedService: 'express', + } as ShippingLines, + fulfillmentSyncKey: null, + lastState: { + serviceCode: 'express', + cost: 100, + inFlightFulfillmentKey: null, + }, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('applies when the existing shipping line cost differs', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine: { + ...shippingLine, + amount: { + value: 200, + }, + } as ShippingLines, + fulfillmentSyncKey: null, + lastState: { + serviceCode: 'ground', + cost: 200, + inFlightFulfillmentKey: null, + }, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('applies when there is no existing shipping line', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine: null, + fulfillmentSyncKey: null, + lastState: { + serviceCode: null, + cost: null, + inFlightFulfillmentKey: null, + }, + }); + + expect(result.needsMutation).toBe(true); + expect(result.alreadyProcessed).toBe(false); + }); + + it('does not treat an unprocessed first render as already processed', () => { + const result = shouldApplyShippingMethod({ + methodToApply: shippingMethod, + shippingLine, + fulfillmentSyncKey: null, + lastState: { + serviceCode: null, + cost: null, + inFlightFulfillmentKey: null, + }, + }); + + expect(result.needsMutation).toBe(false); + expect(result.alreadyProcessed).toBe(false); + }); +}); diff --git a/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts new file mode 100644 index 00000000..1aacf6aa --- /dev/null +++ b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts @@ -0,0 +1,70 @@ +import { DeliveryMethods } from '@/components/checkout/delivery/delivery-method'; +import type { DraftOrder, ShippingLines, ShippingMethod } from '@/types'; + +type LastProcessedShippingState = { + serviceCode: string | null; + cost: number | null; + inFlightFulfillmentKey: string | null; +}; + +export function getShippingFulfillmentSyncKey( + lineItems?: DraftOrder['lineItems'] +): string | null { + const hasLineItemsMissingShippingFulfillment = lineItems?.some( + lineItem => + !lineItem.fulfillmentMode || + lineItem.fulfillmentMode === DeliveryMethods.NONE + ); + + if (!hasLineItemsMissingShippingFulfillment) return null; + + return ( + lineItems + ?.map(lineItem => `${lineItem.id}:${lineItem.fulfillmentMode ?? ''}`) + .join('|') || null + ); +} + +export function shouldApplyShippingMethod({ + methodToApply, + shippingLine, + lastState, + fulfillmentSyncKey, +}: { + methodToApply: ShippingMethod; + shippingLine?: ShippingLines | null; + lastState: LastProcessedShippingState; + fulfillmentSyncKey: string | null; +}) { + const methodCost = methodToApply.cost?.value ?? null; + const currentServiceCode = shippingLine?.requestedService || null; + const currentCost = shippingLine?.amount?.value ?? null; + const hasLineItemsMissingShippingFulfillment = Boolean(fulfillmentSyncKey); + const isFulfillmentSyncInFlight = + Boolean(fulfillmentSyncKey) && + fulfillmentSyncKey === lastState.inFlightFulfillmentKey; + + const alreadyProcessed = + methodToApply.serviceCode === lastState.serviceCode && + methodCost === lastState.cost && + (!hasLineItemsMissingShippingFulfillment || isFulfillmentSyncInFlight); + + if (alreadyProcessed) { + return { + alreadyProcessed, + needsMutation: false, + isFulfillmentSyncInFlight, + methodCost, + }; + } + + return { + alreadyProcessed, + needsMutation: + methodToApply.serviceCode !== currentServiceCode || + methodCost !== currentCost || + (hasLineItemsMissingShippingFulfillment && !isFulfillmentSyncInFlight), + isFulfillmentSyncInFlight, + methodCost, + }; +} From ae778748a3617cc4dcfc3c7b771538a683841ae0 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 28 May 2026 13:05:41 -0500 Subject: [PATCH 3/6] show error if sync fails --- .../checkout/shipping/shipping-method.tsx | 30 ++++++++++--------- .../should-apply-shipping-method.test.ts | 22 +++++++------- .../utils/should-apply-shipping-method.ts | 14 ++++----- 3 files changed, 34 insertions(+), 32 deletions(-) diff --git a/packages/react/src/components/checkout/shipping/shipping-method.tsx b/packages/react/src/components/checkout/shipping/shipping-method.tsx index 877788de..cb4f712c 100644 --- a/packages/react/src/components/checkout/shipping/shipping-method.tsx +++ b/packages/react/src/components/checkout/shipping/shipping-method.tsx @@ -50,7 +50,8 @@ export function ShippingMethodForm() { const formatCurrency = useFormatCurrency(); const form = useFormContext(); const { t } = useGoDaddyContext(); - const { session, isConfirmingCheckout } = useCheckoutContext(); + const { session, isConfirmingCheckout, setCheckoutErrors } = + useCheckoutContext(); const updateTaxes = useUpdateTaxes(); const queryClient = useQueryClient(); const isPaymentDisabled = useIsPaymentDisabled(); @@ -89,14 +90,14 @@ export function ShippingMethodForm() { hadShippingMethods: boolean; wasPickup: boolean; clearedShippingMethod: boolean; - inFlightFulfillmentKey: string | null; + blockedFulfillmentKey: string | null; }>({ serviceCode: null, cost: null, hadShippingMethods: false, wasPickup: false, clearedShippingMethod: false, - inFlightFulfillmentKey: null, + blockedFulfillmentKey: null, }); useEffect(() => { @@ -114,11 +115,11 @@ export function ShippingMethodForm() { if ( !hasLineItemsMissingShippingFulfillment && - lastState.inFlightFulfillmentKey + lastState.blockedFulfillmentKey ) { lastProcessedStateRef.current = { ...lastState, - inFlightFulfillmentKey: null, + blockedFulfillmentKey: null, }; } @@ -140,7 +141,7 @@ export function ShippingMethodForm() { hadShippingMethods: false, wasPickup: isPickup, clearedShippingMethod: true, - inFlightFulfillmentKey: null, + blockedFulfillmentKey: null, }; } return; @@ -192,7 +193,7 @@ export function ShippingMethodForm() { if (isFulfillmentSync) { lastProcessedStateRef.current = { ...lastProcessedStateRef.current, - inFlightFulfillmentKey: fulfillmentSyncKey, + blockedFulfillmentKey: fulfillmentSyncKey, }; } @@ -205,12 +206,12 @@ export function ShippingMethodForm() { }); }, onError: () => { - if (!isFulfillmentSync) return; + if (!isFulfillmentSync || !session?.id) return; - lastProcessedStateRef.current = { - ...lastProcessedStateRef.current, - inFlightFulfillmentKey: null, - }; + setCheckoutErrors(['SHIPPING_METHOD_APPLICATION_FAILED']); + queryClient.invalidateQueries({ + queryKey: ['draft-order', session.id], + }); }, }); } else if (session?.enableTaxCollection) { @@ -223,8 +224,8 @@ export function ShippingMethodForm() { hadShippingMethods: true, wasPickup: false, clearedShippingMethod: false, - inFlightFulfillmentKey: needsMutation - ? lastProcessedStateRef.current.inFlightFulfillmentKey + blockedFulfillmentKey: needsMutation + ? lastProcessedStateRef.current.blockedFulfillmentKey : null, }; } @@ -238,6 +239,7 @@ export function ShippingMethodForm() { form, applyShippingMethod, updateTaxes.mutate, + setCheckoutErrors, session?.enableTaxCollection, queryClient, session?.id, diff --git a/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts index 480e84b7..2d82f625 100644 --- a/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts +++ b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.test.ts @@ -23,7 +23,7 @@ const shippingLine = { const processedState = { serviceCode: 'ground', cost: 100, - inFlightFulfillmentKey: null, + blockedFulfillmentKey: null, }; describe('getShippingFulfillmentSyncKey', () => { @@ -78,23 +78,23 @@ describe('shouldApplyShippingMethod', () => { expect(result.alreadyProcessed).toBe(false); }); - it('does not repeatedly apply while the same fulfillment sync is in flight', () => { + it('does not repeatedly apply while the same fulfillment sync key is blocked', () => { const result = shouldApplyShippingMethod({ methodToApply: shippingMethod, shippingLine, fulfillmentSyncKey: 'line-item-1:NONE', lastState: { ...processedState, - inFlightFulfillmentKey: 'line-item-1:NONE', + blockedFulfillmentKey: 'line-item-1:NONE', }, }); expect(result.needsMutation).toBe(false); expect(result.alreadyProcessed).toBe(true); - expect(result.isFulfillmentSyncInFlight).toBe(true); + expect(result.isFulfillmentSyncBlocked).toBe(true); }); - it('allows retrying after the in-flight fulfillment key is cleared', () => { + it('allows retrying after the blocked fulfillment key changes', () => { const result = shouldApplyShippingMethod({ methodToApply: shippingMethod, shippingLine, @@ -113,13 +113,13 @@ describe('shouldApplyShippingMethod', () => { fulfillmentSyncKey: 'line-item-2:NONE', lastState: { ...processedState, - inFlightFulfillmentKey: 'line-item-1:NONE', + blockedFulfillmentKey: 'line-item-1:NONE', }, }); expect(result.needsMutation).toBe(true); expect(result.alreadyProcessed).toBe(false); - expect(result.isFulfillmentSyncInFlight).toBe(false); + expect(result.isFulfillmentSyncBlocked).toBe(false); }); it('does not apply when shipping line and method match and line items are fulfilled', () => { @@ -160,7 +160,7 @@ describe('shouldApplyShippingMethod', () => { lastState: { serviceCode: 'express', cost: 100, - inFlightFulfillmentKey: null, + blockedFulfillmentKey: null, }, }); @@ -181,7 +181,7 @@ describe('shouldApplyShippingMethod', () => { lastState: { serviceCode: 'ground', cost: 200, - inFlightFulfillmentKey: null, + blockedFulfillmentKey: null, }, }); @@ -197,7 +197,7 @@ describe('shouldApplyShippingMethod', () => { lastState: { serviceCode: null, cost: null, - inFlightFulfillmentKey: null, + blockedFulfillmentKey: null, }, }); @@ -213,7 +213,7 @@ describe('shouldApplyShippingMethod', () => { lastState: { serviceCode: null, cost: null, - inFlightFulfillmentKey: null, + blockedFulfillmentKey: null, }, }); diff --git a/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts index 1aacf6aa..d43f470b 100644 --- a/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts +++ b/packages/react/src/components/checkout/shipping/utils/should-apply-shipping-method.ts @@ -4,7 +4,7 @@ import type { DraftOrder, ShippingLines, ShippingMethod } from '@/types'; type LastProcessedShippingState = { serviceCode: string | null; cost: number | null; - inFlightFulfillmentKey: string | null; + blockedFulfillmentKey: string | null; }; export function getShippingFulfillmentSyncKey( @@ -40,20 +40,20 @@ export function shouldApplyShippingMethod({ const currentServiceCode = shippingLine?.requestedService || null; const currentCost = shippingLine?.amount?.value ?? null; const hasLineItemsMissingShippingFulfillment = Boolean(fulfillmentSyncKey); - const isFulfillmentSyncInFlight = + const isFulfillmentSyncBlocked = Boolean(fulfillmentSyncKey) && - fulfillmentSyncKey === lastState.inFlightFulfillmentKey; + fulfillmentSyncKey === lastState.blockedFulfillmentKey; const alreadyProcessed = methodToApply.serviceCode === lastState.serviceCode && methodCost === lastState.cost && - (!hasLineItemsMissingShippingFulfillment || isFulfillmentSyncInFlight); + (!hasLineItemsMissingShippingFulfillment || isFulfillmentSyncBlocked); if (alreadyProcessed) { return { alreadyProcessed, needsMutation: false, - isFulfillmentSyncInFlight, + isFulfillmentSyncBlocked, methodCost, }; } @@ -63,8 +63,8 @@ export function shouldApplyShippingMethod({ needsMutation: methodToApply.serviceCode !== currentServiceCode || methodCost !== currentCost || - (hasLineItemsMissingShippingFulfillment && !isFulfillmentSyncInFlight), - isFulfillmentSyncInFlight, + (hasLineItemsMissingShippingFulfillment && !isFulfillmentSyncBlocked), + isFulfillmentSyncBlocked, methodCost, }; } From 8981bc06b2dd721aeb442de921859a42ae18b430 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 28 May 2026 13:20:27 -0500 Subject: [PATCH 4/6] add confirmCheckout blocker --- .../payment/utils/use-confirm-checkout.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index f83a0591..6700c408 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -6,6 +6,7 @@ import { useCheckoutContext, } from '@/components/checkout/checkout'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-method'; +import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { buildPickupPayload } from '@/components/checkout/pickup/utils/build-pickup-payload'; import { useGoDaddyContext } from '@/godaddy-provider'; import { confirmCheckout } from '@/lib/godaddy/godaddy'; @@ -72,6 +73,7 @@ export function useConfirmCheckout() { useCheckoutContext(); const { apiHost } = useGoDaddyContext(); const form = useFormContext(); + const { data: order } = useDraftOrder(); const isPendingRef = useRef(false); return useMutation({ @@ -87,9 +89,20 @@ export function useConfirmCheckout() { const { isExpress, ...confirmCheckoutInput } = input; - const isPickup = - form.getValues('deliveryMethod') === DeliveryMethods.PICKUP && - !isExpress; + const deliveryMethod = form.getValues('deliveryMethod'); + const isPickup = deliveryMethod === DeliveryMethods.PICKUP && !isExpress; + const isShipping = deliveryMethod === DeliveryMethods.SHIP && !isExpress; + + const hasShippingLines = (order?.shippingLines?.length ?? 0) > 0; + const hasNonShipLineItems = order?.lineItems?.some( + lineItem => lineItem.fulfillmentMode !== DeliveryMethods.SHIP + ); + + if (isShipping && (!hasShippingLines || hasNonShipLineItems)) { + setCheckoutErrors(['SHIPPING_METHOD_APPLICATION_FAILED']); + isPendingRef.current = false; + return; + } const pickUpData = isPickup ? buildPickupPayload({ From 3a00d7d457453e725651586f5a0aa9a390e53445 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 28 May 2026 13:23:55 -0500 Subject: [PATCH 5/6] throw actual error --- .../components/checkout/payment/utils/use-confirm-checkout.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index 6700c408..d75cfed7 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -100,8 +100,7 @@ export function useConfirmCheckout() { if (isShipping && (!hasShippingLines || hasNonShipLineItems)) { setCheckoutErrors(['SHIPPING_METHOD_APPLICATION_FAILED']); - isPendingRef.current = false; - return; + throw new Error('SHIPPING_METHOD_APPLICATION_FAILED'); } const pickUpData = isPickup From 3322ea5aa15e60f824f34490dcaa991e61ac9889 Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 28 May 2026 13:29:07 -0500 Subject: [PATCH 6/6] reuse sync key logic --- .../checkout/payment/utils/use-confirm-checkout.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts index d75cfed7..33680d0c 100644 --- a/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts +++ b/packages/react/src/components/checkout/payment/utils/use-confirm-checkout.ts @@ -8,6 +8,7 @@ import { import { DeliveryMethods } from '@/components/checkout/delivery/delivery-method'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { buildPickupPayload } from '@/components/checkout/pickup/utils/build-pickup-payload'; +import { getShippingFulfillmentSyncKey } from '@/components/checkout/shipping/utils/should-apply-shipping-method'; import { useGoDaddyContext } from '@/godaddy-provider'; import { confirmCheckout } from '@/lib/godaddy/godaddy'; import { eventIds } from '@/tracking/events'; @@ -94,11 +95,14 @@ export function useConfirmCheckout() { const isShipping = deliveryMethod === DeliveryMethods.SHIP && !isExpress; const hasShippingLines = (order?.shippingLines?.length ?? 0) > 0; - const hasNonShipLineItems = order?.lineItems?.some( - lineItem => lineItem.fulfillmentMode !== DeliveryMethods.SHIP + const hasLineItemsMissingShippingFulfillment = Boolean( + getShippingFulfillmentSyncKey(order?.lineItems) ); - if (isShipping && (!hasShippingLines || hasNonShipLineItems)) { + if ( + isShipping && + (!hasShippingLines || hasLineItemsMissingShippingFulfillment) + ) { setCheckoutErrors(['SHIPPING_METHOD_APPLICATION_FAILED']); throw new Error('SHIPPING_METHOD_APPLICATION_FAILED'); }