From 1fffef069603dc277cf9759eac23fe8eb6e1e6ec Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Mon, 3 Aug 2026 09:40:19 -0500 Subject: [PATCH 1/2] add field sync controller --- .changeset/checkout-final-draft-order-sync.md | 15 + .../__tests__/checkout-address.test.tsx | 6 +- .../checkout-confirm-errors.test.tsx | 17 +- .../checkout-draft-order-sync.test.tsx | 201 +++++++ .../checkout/address/address-form.tsx | 491 +++++++++--------- .../checkout/contact/contact-form.tsx | 89 ++-- .../checkout/contact/phone-input.tsx | 84 +-- .../components/checkout/notes/notes-form.tsx | 63 +-- ...t-order-sync-provider.integration.test.tsx | 134 ++++- .../order/draft-order-sync-provider.tsx | 346 ++++++++++-- .../checkout/order/use-draft-order-sync.ts | 159 ++---- .../payment/utils/use-confirm-checkout.ts | 37 +- .../payment/utils/use-flush-checkout-sync.ts | 65 ++- 13 files changed, 1119 insertions(+), 588 deletions(-) create mode 100644 .changeset/checkout-final-draft-order-sync.md diff --git a/.changeset/checkout-final-draft-order-sync.md b/.changeset/checkout-final-draft-order-sync.md new file mode 100644 index 00000000..c4c45f2a --- /dev/null +++ b/.changeset/checkout-final-draft-order-sync.md @@ -0,0 +1,15 @@ +--- +'@godaddy/react': patch +--- + +Centralize draft-order syncing behind a registration-based sync controller and run a +single final sync before checkout confirmation. + +- Form sections (contact, phone, address, notes) now register how their current values + map to a draft-order patch instead of firing their own debounced updates. +- On confirm, checkout drains any queued sync work, diffs the current form values against + the latest backend draft order, sends at most one final update, and refetches only when + that update was sent — so in-flight edits (including name-only edits and pickup names) + are no longer lost or duplicated. +- Background sync is suppressed once confirmation starts; only the final checkout sync may + still write, and a failed final update blocks confirmation and surfaces the error. \ No newline at end of file diff --git a/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx index 22c3bbd6..aa1c8a67 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-address.test.tsx @@ -343,7 +343,11 @@ describe('Checkout address behavior', () => { await typeIntoNamedField(user, 'shippingAddressLine1', '456 Shipping Ln'); await advanceCheckoutDebounce(); - expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(1); + expect(getLastUpdateInput()).toMatchObject({ + shipping: { firstName: 'Ship', lastName: 'Buyer' }, + }); + expect(getLastUpdateInput()?.shipping).not.toHaveProperty('address'); // Provide remaining fields → sync fires once with the full address. await typeIntoNamedField(user, 'shippingAdminArea2', 'Jasper'); diff --git a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx index 3d4204f4..83c88a4c 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-confirm-errors.test.tsx @@ -307,16 +307,11 @@ describe('Checkout confirm errors', () => { }); mockGodaddyApi({ session, draftOrder }); - const { user, queryClient } = renderCheckoutWithConfirmSeam({ + const { user } = renderCheckoutWithConfirmSeam({ session, draftOrder, }); await waitForCheckoutReady(); - queryClient.setQueryDefaults(['draft-order', { sessionId: session.id }], { - retry: false, - refetchOnWindowFocus: false, - staleTime: 0, - }); setApiError('getDraftOrder', 'draft fetch failed'); clearOperations(); @@ -325,8 +320,16 @@ describe('Checkout confirm errors', () => { ); await waitForOperation('DraftOrder'); + // `useDraftOrder` keeps `retry: 3`, so the failed in-confirm fetch only + // rejects after its backoff retries are exhausted. + await waitFor( + () => { + expect(document.body).toHaveTextContent(/Failed to update order/i); + }, + { timeout: 15_000 } + ); expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); - }); + }, 20_000); it('rejects a duplicate confirm while the first confirm is in flight without treating it as success', async () => { const draftOrder = buildDraftOrder({ diff --git a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx index e62bbeb9..7bb7bd4f 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx @@ -11,6 +11,8 @@ import { flushPromises, getCurrentDraftOrder, getNamedInput, + getOperationNames, + getOperationOrder, getOperations, renderCheckout, setApiError, @@ -614,6 +616,205 @@ describe('Checkout draft-order field sync', () => { }); }); + it('syncs current notes before immediate offline confirmation', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + const notes = document.querySelector( + 'textarea[name="notes"]' + ); + expect(notes).toBeTruthy(); + await user.clear(notes as HTMLTextAreaElement); + await user.type(notes as HTMLTextAreaElement, 'Race note'); + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + const [updateIdx, confirmIdx] = getOperationOrder([ + 'UpdateCheckoutSessionDraftOrder', + 'ConfirmCheckoutSession', + ]); + expect(updateIdx).toBeGreaterThanOrEqual(0); + expect(confirmIdx).toBeGreaterThan(updateIdx); + expect(getLastUpdateInput()).toMatchObject({ + notes: [{ authorType: 'CUSTOMER', content: 'Race note' }], + }); + }); + + it('syncs current shipping name before immediate offline confirmation', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await typeIntoNamedField(user, 'shippingFirstName', 'Race'); + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + const [updateIdx, confirmIdx] = getOperationOrder([ + 'UpdateCheckoutSessionDraftOrder', + 'ConfirmCheckoutSession', + ]); + expect(updateIdx).toBeGreaterThanOrEqual(0); + expect(confirmIdx).toBeGreaterThan(updateIdx); + expect(getLastUpdateInput()).toMatchObject({ + shipping: { firstName: 'Race', lastName: 'Buyer' }, + billing: { firstName: 'Race', lastName: 'Buyer' }, + }); + }); + + it('syncs names-only billing edits before immediate free-pickup confirmation', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + billing: { + firstName: '', + lastName: '', + phone: '', + email: 'jane@example.com', + address: null, + }, + lineItems: [{ fulfillmentMode: DeliveryMethods.PICKUP }], + }, + sessionOverrides: { + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + // No debounce advance: the names are only in the form when Pay is clicked. + await typeIntoNamedField(user, 'billingFirstName', 'Pickup'); + await typeIntoNamedField(user, 'billingLastName', 'Person'); + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + const [updateIndex, confirmIndex] = getOperationOrder([ + 'UpdateCheckoutSessionDraftOrder', + 'ConfirmCheckoutSession', + ]); + expect(updateIndex).toBeGreaterThanOrEqual(0); + expect(confirmIndex).toBeGreaterThan(updateIndex); + expect(getLastUpdateInput()).toMatchObject({ + billing: { firstName: 'Pickup', lastName: 'Person' }, + }); + }); + + it('disables form edits while the final sync runs', async () => { + const { user } = renderCheckout({ + apiOverrides: { delayMs: 100 }, + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + const payButton = await screen.findByRole('button', { + name: /complete your order/i, + }); + await waitFor(() => { + expect(payButton).not.toBeDisabled(); + }); + + await typeIntoNamedField(user, 'shippingFirstName', 'Locked'); + const click = user.click(payButton); + + await waitFor(() => { + expect(getNamedInput('shippingFirstName')).toBeDisabled(); + }); + + await click; + await waitForOperation('ConfirmCheckoutSession'); + }); + + it('refetches the draft order after the final sync patch and before confirming', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await typeIntoNamedField(user, 'shippingFirstName', 'Refetch'); + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + const operations = getOperationNames(); + const updateIndex = operations.indexOf('UpdateCheckoutSessionDraftOrder'); + const confirmIndex = operations.indexOf('ConfirmCheckoutSession'); + const refetchIndex = operations.findIndex( + (name, index) => name === 'DraftOrder' && index > updateIndex + ); + + expect(updateIndex).toBeGreaterThanOrEqual(0); + expect(refetchIndex).toBeGreaterThan(updateIndex); + expect(confirmIndex).toBeGreaterThan(refetchIndex); + }); + + it('does not send a final sync patch when the form already matches the order', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('ConfirmCheckoutSession'); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + }); + + it('blocks confirmation and surfaces a sync error when the final sync patch fails', async () => { + const { user } = renderCheckout({ + sessionOverrides: { + paymentMethods: offlinePaymentMethods(), + }, + }); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + setApiError('updateDraftOrder', new Error('update failed')); + + await typeIntoNamedField(user, 'shippingFirstName', 'Broken'); + await user.click( + await screen.findByRole('button', { name: /complete your order/i }) + ); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + await waitFor(() => { + expect(document.body).toHaveTextContent(/Failed to update order/i); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + }); + it('resetField after a successful sync makes the typed value pristine for later refetches', async () => { const { user, queryClient, session } = renderCheckout({ draftOrderOverrides: { shipping: { firstName: '' } }, diff --git a/packages/react/src/components/checkout/address/address-form.tsx b/packages/react/src/components/checkout/address/address-form.tsx index 73cc8894..a1833150 100644 --- a/packages/react/src/components/checkout/address/address-form.tsx +++ b/packages/react/src/components/checkout/address/address-form.tsx @@ -12,10 +12,16 @@ import { import { isAddressComplete } from '@/components/checkout/address/utils/is-address-complete'; import { mapAddressFieldsToInput } from '@/components/checkout/address/utils/map-address-fields-to-input'; import { useAddressMatches } from '@/components/checkout/address/utils/use-address-matches'; -import { useCheckoutContext } from '@/components/checkout/checkout'; +import { + type CheckoutFormData, + useCheckoutContext, +} from '@/components/checkout/checkout'; import { PhoneInput } from '@/components/checkout/contact/phone-input'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import { useDraftOrderFieldSync } from '@/components/checkout/order/use-draft-order-sync'; +import { + useDraftOrderFieldDirtyMarker, + useRegisterDraftOrderFieldSync, +} from '@/components/checkout/order/use-draft-order-sync'; import { AutoComplete } from '@/components/ui/autocomplete'; import { Button } from '@/components/ui/button'; import { @@ -50,10 +56,12 @@ import { useGoDaddyContext } from '@/godaddy-provider'; import { cn } from '@/lib/utils'; import { eventIds } from '@/tracking/events'; import { TrackingEventType, track } from '@/tracking/track'; -import type { Address } from '@/types'; +import type { Address, DraftOrder } from '@/types'; + +type SectionKey = 'shipping' | 'billing'; interface AddressFormProps { - sectionKey: string; + sectionKey: SectionKey; /** When true, only show first name and last name fields (used for free pickup orders) */ onlyNames?: boolean; } @@ -70,11 +78,101 @@ export function mapAutocompleteAddressFields(selectedAddress?: Address) { } satisfies Record; } +const addressFieldSuffixes = [ + 'AddressLine1', + 'AddressLine2', + 'AddressLine3', + 'AdminArea4', + 'AdminArea3', + 'AdminArea2', + 'AdminArea1', + 'PostalCode', + 'CountryCode', +] as const; + +function getFormString(values: CheckoutFormData, fieldName: string) { + return String(values[fieldName as keyof CheckoutFormData] ?? ''); +} + +function getSectionAddress(values: CheckoutFormData, sectionKey: SectionKey) { + return { + addressLine1: getFormString(values, `${sectionKey}AddressLine1`), + addressLine2: getFormString(values, `${sectionKey}AddressLine2`), + addressLine3: getFormString(values, `${sectionKey}AddressLine3`), + adminArea4: getFormString(values, `${sectionKey}AdminArea4`), + adminArea3: getFormString(values, `${sectionKey}AdminArea3`), + adminArea2: getFormString(values, `${sectionKey}AdminArea2`), + adminArea1: getFormString(values, `${sectionKey}AdminArea1`), + postalCode: getFormString(values, `${sectionKey}PostalCode`), + countryCode: getFormString(values, `${sectionKey}CountryCode`), + }; +} + +function getDraftOrderSection( + draftOrder: DraftOrder | null | undefined, + sectionKey: SectionKey +) { + return sectionKey === 'shipping' ? draftOrder?.shipping : draftOrder?.billing; +} + +function getDraftOrderAddress( + draftOrder: DraftOrder | null | undefined, + sectionKey: SectionKey +) { + const section = getDraftOrderSection(draftOrder, sectionKey); + return { + addressLine1: section?.address?.addressLine1 || '', + addressLine2: section?.address?.addressLine2 || '', + addressLine3: section?.address?.addressLine3 || '', + adminArea4: section?.address?.adminArea4 || '', + adminArea3: section?.address?.adminArea3 || '', + adminArea2: section?.address?.adminArea2 || '', + adminArea1: section?.address?.adminArea1 || '', + postalCode: section?.address?.postalCode || '', + countryCode: section?.address?.countryCode || '', + }; +} + +function sectionNameHasChanged( + values: CheckoutFormData, + draftOrder: DraftOrder | null | undefined, + sectionKey: SectionKey +) { + const section = getDraftOrderSection(draftOrder, sectionKey); + return ( + (section?.firstName || '') !== + getFormString(values, `${sectionKey}FirstName`) || + (section?.lastName || '') !== getFormString(values, `${sectionKey}LastName`) + ); +} + +function sectionAddressHasChanged( + values: CheckoutFormData, + draftOrder: DraftOrder | null | undefined, + sectionKey: SectionKey +) { + if (!draftOrder) return false; + + const orderAddress = getDraftOrderAddress(draftOrder, sectionKey); + const formAddress = getSectionAddress(values, sectionKey); + const orderSection = getDraftOrderSection(draftOrder, sectionKey); + + if (!orderSection?.address) { + return Object.entries(formAddress).some( + ([key, value]) => key !== 'countryCode' && Boolean(value.trim()) + ); + } + + return Object.entries(orderAddress).some( + ([key, value]) => value !== formAddress[key as keyof typeof formAddress] + ); +} + export function AddressForm({ sectionKey, onlyNames = false, }: AddressFormProps) { - const form = useFormContext(); + const form = useFormContext(); const { session } = useCheckoutContext(); const { t } = useGoDaddyContext(); const { isConfirmingCheckout, requiredFields } = useCheckoutContext(); @@ -98,216 +196,158 @@ export function AddressForm({ return () => window.removeEventListener('resize', updateWidth); }, []); - const addressValue = form.watch(`${sectionKey}AddressLine1`); - const countryValue = form.watch(`${sectionKey}CountryCode`); - const useShippingAddress = form.watch('paymentUseShippingAddress'); - - const [ - firstName, - lastName, - addressLine1, - addressLine2, - addressLine3, - adminArea1, - adminArea2, - adminArea3, - adminArea4, - postalCode, - countryCode, - ] = form.watch([ - `${sectionKey}FirstName`, - `${sectionKey}LastName`, + const [addressValue, countryValue] = form.watch([ `${sectionKey}AddressLine1`, - `${sectionKey}AddressLine2`, - `${sectionKey}AddressLine3`, - `${sectionKey}AdminArea1`, - `${sectionKey}AdminArea2`, - `${sectionKey}AdminArea3`, - `${sectionKey}AdminArea4`, - `${sectionKey}PostalCode`, `${sectionKey}CountryCode`, ]); - const contact = React.useMemo( - () => ({ firstName, lastName }), - [firstName, lastName] - ); - const serializedContact = React.useMemo( - () => JSON.stringify(contact), - [contact] - ); - - const [debouncedContact] = useDebouncedValue(serializedContact, { - wait: 1000, - }); - const [debouncedAddressValue] = useDebouncedValue(addressValue, { wait: 200, }); - // Check if name values differ from order values - const nameHasChanged = React.useMemo(() => { - if (!draftOrder) return true; // If no order, allow sync - const section = - sectionKey === 'shipping' ? draftOrder.shipping : draftOrder.billing; - - return ( - (section?.firstName || '') !== (firstName || '') || - (section?.lastName || '') !== (lastName || '') - ); - }, [draftOrder, sectionKey, firstName, lastName]); - - const shouldVerifyName = - onlyNames && - nameHasChanged && // Only sync if values differ from order - !!firstName?.trim() && - !!lastName?.trim() && - debouncedContact === serializedContact; - - useDraftOrderFieldSync({ - key: 'name', - data: contact, - deps: [contact, serializedContact, debouncedContact], - enabled: shouldVerifyName, - fieldNames: [`${sectionKey}FirstName`, `${sectionKey}LastName`], - preserveFormData: false, - mapToInput: data => { - const fields = { - firstName: data.firstName.trim(), - lastName: data.lastName.trim(), - address: null, - }; - - return mapAddressFieldsToInput( - fields, - sectionKey as 'shipping' | 'billing', - useShippingAddress - ); - }, - }); - - const address = React.useMemo( - () => ({ - addressLine1, - addressLine2, - addressLine3, - adminArea1, - adminArea2, - adminArea3, - adminArea4, - postalCode, - countryCode, - }), - [ - addressLine1, - addressLine2, - addressLine3, - adminArea1, - adminArea2, - adminArea3, - adminArea4, - postalCode, - countryCode, - ] + const nameFieldNames = React.useMemo( + () => [`${sectionKey}FirstName`, `${sectionKey}LastName`], + [sectionKey] ); - - const sectionContactAndAddress = React.useMemo( - () => ({ - ...contact, - address, - }), - [contact, address] + const allAddressFieldNames = React.useMemo( + () => addressFieldSuffixes.map(suffix => `${sectionKey}${suffix}`), + [sectionKey] ); - const serializedSectionContactAndAddress = React.useMemo( - () => JSON.stringify(sectionContactAndAddress), - [sectionContactAndAddress] + const allSectionFieldNames = React.useMemo( + () => [...nameFieldNames, ...allAddressFieldNames], + [allAddressFieldNames, nameFieldNames] ); - const [debouncedSectionContactAndAddress] = useDebouncedValue( - serializedSectionContactAndAddress, - { wait: 1000 } + const orderAddress = React.useMemo( + () => getDraftOrderAddress(draftOrder, sectionKey), + [draftOrder, sectionKey] ); - // Get existing order address data for comparison - const orderAddress = React.useMemo(() => { - if (!draftOrder) return null; - const section = - sectionKey === 'shipping' ? draftOrder.shipping : draftOrder.billing; - return section - ? { - addressLine1: section?.address?.addressLine1 || '', - addressLine2: section?.address?.addressLine2 || '', - addressLine3: section?.address?.addressLine3 || '', - adminArea1: section?.address?.adminArea1 || '', - adminArea2: section?.address?.adminArea2 || '', - adminArea3: section?.address?.adminArea3 || '', - adminArea4: section?.address?.adminArea4 || '', - postalCode: section?.address?.postalCode || '', - countryCode: section?.address?.countryCode || '', - } - : null; - }, [draftOrder, sectionKey]); - - // Check if current form values differ from order values - const addressHasChanged = React.useMemo(() => { - if (!orderAddress) return true; // If no order address, allow sync - - return ( - orderAddress.addressLine1 !== (addressLine1 || '') || - orderAddress.addressLine2 !== (addressLine2 || '') || - orderAddress.addressLine3 !== (addressLine3 || '') || - orderAddress.adminArea1 !== (adminArea1 || '') || - orderAddress.adminArea2 !== (adminArea2 || '') || - orderAddress.adminArea3 !== (adminArea3 || '') || - orderAddress.adminArea4 !== (adminArea4 || '') || - orderAddress.postalCode !== (postalCode || '') || - orderAddress.countryCode !== (countryCode || '') - ); - }, [ - orderAddress, - addressLine1, - addressLine2, - addressLine3, - adminArea1, - adminArea2, - adminArea3, - adminArea4, - postalCode, - countryCode, - ]); + const addressLine1HasChanged = React.useMemo( + () => + Boolean(draftOrder && orderAddress.addressLine1 !== (addressValue || '')), + [draftOrder, orderAddress, addressValue] + ); - const addressLine1HasChanged = React.useMemo(() => { - if (!orderAddress) return true; + useRegisterDraftOrderFieldSync( + React.useMemo( + () => ({ + id: `${sectionKey}-names-only`, + fieldNames: nameFieldNames, + debounceMs: 1000, + enabled: ({ values, draftOrder: currentDraftOrder }) => + Boolean( + onlyNames && + sectionNameHasChanged(values, currentDraftOrder, sectionKey) && + getFormString(values, `${sectionKey}FirstName`).trim() && + getFormString(values, `${sectionKey}LastName`).trim() + ), + buildPatch: ({ values }) => + mapAddressFieldsToInput( + { + firstName: getFormString(values, `${sectionKey}FirstName`).trim(), + lastName: getFormString(values, `${sectionKey}LastName`).trim(), + address: null, + }, + sectionKey, + Boolean(values.paymentUseShippingAddress) + ), + }), + [nameFieldNames, onlyNames, sectionKey] + ) + ); - return orderAddress.addressLine1 !== (addressLine1 || ''); - }, [orderAddress, addressLine1]); + useRegisterDraftOrderFieldSync( + React.useMemo( + () => ({ + id: `${sectionKey}-name`, + fieldNames: nameFieldNames, + debounceMs: 1000, + enabled: ({ values, draftOrder: currentDraftOrder }) => + Boolean( + !onlyNames && + sectionNameHasChanged(values, currentDraftOrder, sectionKey) && + (sectionKey === 'shipping' || + !sectionAddressHasChanged( + values, + currentDraftOrder, + sectionKey + )) && + getFormString(values, `${sectionKey}FirstName`).trim() && + getFormString(values, `${sectionKey}LastName`).trim() + ), + buildPatch: ({ values }) => + mapAddressFieldsToInput( + { + firstName: getFormString(values, `${sectionKey}FirstName`).trim(), + lastName: getFormString(values, `${sectionKey}LastName`).trim(), + }, + sectionKey, + Boolean(values.paymentUseShippingAddress) + ), + }), + [nameFieldNames, onlyNames, sectionKey] + ) + ); - const shouldUpdateNameOnly = Boolean( - nameHasChanged && - !addressHasChanged && - !!firstName?.trim() && - !!lastName?.trim() && - debouncedContact === serializedContact + useRegisterDraftOrderFieldSync( + React.useMemo( + () => ({ + id: `${sectionKey}-address`, + fieldNames: allSectionFieldNames, + debounceMs: 1000, + enabled: ({ values, draftOrder: currentDraftOrder }) => + Boolean( + !onlyNames && + sectionAddressHasChanged(values, currentDraftOrder, sectionKey) && + isAddressComplete(getSectionAddress(values, sectionKey)) && + !isAutocompleteOpen + ), + buildPatch: ({ values }) => { + const hasCompleteName = Boolean( + getFormString(values, `${sectionKey}FirstName`).trim() && + getFormString(values, `${sectionKey}LastName`).trim() + ); + + return mapAddressFieldsToInput( + { + ...(hasCompleteName + ? { + firstName: getFormString( + values, + `${sectionKey}FirstName` + ).trim(), + lastName: getFormString( + values, + `${sectionKey}LastName` + ).trim(), + } + : {}), + address: getSectionAddress(values, sectionKey), + }, + sectionKey, + Boolean(values.paymentUseShippingAddress) + ); + }, + }), + [allSectionFieldNames, isAutocompleteOpen, onlyNames, sectionKey] + ) ); - useDraftOrderFieldSync({ - key: 'name', - data: contact, - deps: [contact, serializedContact, debouncedContact, addressHasChanged], - enabled: !onlyNames && shouldUpdateNameOnly, - fieldNames: [`${sectionKey}FirstName`, `${sectionKey}LastName`], - mapToInput: data => { - const fields = { - firstName: data.firstName.trim(), - lastName: data.lastName.trim(), - }; - - return mapAddressFieldsToInput( - fields, - sectionKey as 'shipping' | 'billing', - useShippingAddress - ); - }, + useDraftOrderFieldDirtyMarker({ + id: `${sectionKey}-names-only`, + fieldNames: nameFieldNames, + disabled: !onlyNames || isConfirmingCheckout, + }); + useDraftOrderFieldDirtyMarker({ + id: `${sectionKey}-name`, + fieldNames: nameFieldNames, + disabled: onlyNames || isConfirmingCheckout, + }); + useDraftOrderFieldDirtyMarker({ + id: `${sectionKey}-address`, + fieldNames: allSectionFieldNames, + disabled: onlyNames || isConfirmingCheckout, }); const addressMatchesQuery = useAddressMatches(debouncedAddressValue, { @@ -324,8 +364,9 @@ export function AddressForm({ for (const [key, value] of Object.entries( mapAutocompleteAddressFields(selectedAddress) )) { - if (value && form.getValues(`${sectionKey}${key}`) !== value) { - form.setValue(`${sectionKey}${key}`, value, { + const fieldName = `${sectionKey}${key}` as keyof CheckoutFormData; + if (value && form.getValues(fieldName) !== value) { + form.setValue(fieldName, value, { shouldDirty: true, shouldValidate: true, }); @@ -333,60 +374,6 @@ export function AddressForm({ } } - const shouldUpdateAddress = Boolean( - addressHasChanged && // Only sync if address values differ from order - isAddressComplete(address) && - debouncedSectionContactAndAddress === - serializedSectionContactAndAddress && - !isAutocompleteOpen - ); - - const hasCompleteName = Boolean(firstName?.trim() && lastName?.trim()); - const addressSyncFieldNames = React.useMemo( - () => [ - ...(hasCompleteName - ? [`${sectionKey}FirstName`, `${sectionKey}LastName`] - : []), - `${sectionKey}AddressLine1`, - `${sectionKey}AddressLine2`, - `${sectionKey}AdminArea2`, - `${sectionKey}AdminArea1`, - `${sectionKey}PostalCode`, - `${sectionKey}CountryCode`, - ], - [hasCompleteName, sectionKey] - ); - - useDraftOrderFieldSync({ - key: 'address', - data: sectionContactAndAddress, - deps: [ - sectionContactAndAddress, - shouldUpdateAddress, - serializedSectionContactAndAddress, - debouncedSectionContactAndAddress, - ], - enabled: !onlyNames && shouldUpdateAddress, - fieldNames: addressSyncFieldNames, - mapToInput: data => { - const fields = { - ...(hasCompleteName - ? { - firstName: data.firstName.trim(), - lastName: data.lastName.trim(), - } - : {}), - address: data.address, - }; - - return mapAddressFieldsToInput( - fields, - sectionKey as 'shipping' | 'billing', - useShippingAddress - ); - }, - }); - return (
{!onlyNames && ( diff --git a/packages/react/src/components/checkout/contact/contact-form.tsx b/packages/react/src/components/checkout/contact/contact-form.tsx index 5e1102cb..954288a9 100644 --- a/packages/react/src/components/checkout/contact/contact-form.tsx +++ b/packages/react/src/components/checkout/contact/contact-form.tsx @@ -1,11 +1,12 @@ 'use client'; -import { useDebouncedValue } from '@tanstack/react-pacer'; import { useMemo } from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import { useDraftOrderFieldSync } from '@/components/checkout/order/use-draft-order-sync'; +import { + useDraftOrderFieldDirtyMarker, + useRegisterDraftOrderFieldSync, +} from '@/components/checkout/order/use-draft-order-sync'; import { FormControl, FormField, @@ -22,58 +23,42 @@ export function ContactForm() { const form = useFormContext(); const { t } = useGoDaddyContext(); const { isConfirmingCheckout, requiredFields } = useCheckoutContext(); - const { data: draftOrder } = useDraftOrder(); - - const contactEmail = form.watch('contactEmail'); - - // Check if email values differ from order values - const emailHasChanged = useMemo(() => { - if (!draftOrder) return true; // If no order, allow sync - - const shippingEmailMissing = !draftOrder?.shipping?.email; - const billingEmailMissing = !draftOrder?.billing?.email; - - const shippingIsDifferent = draftOrder?.shipping?.email !== contactEmail; - const billingIsDifferent = draftOrder?.billing?.email !== contactEmail; - return ( - !!contactEmail?.trim() && - (shippingEmailMissing || - billingEmailMissing || - shippingIsDifferent || - billingIsDifferent) - ); - }, [draftOrder, contactEmail]); + useRegisterDraftOrderFieldSync( + useMemo( + () => ({ + id: 'contact-email', + fieldNames: ['contactEmail'], + debounceMs: 1000, + enabled: ({ values, draftOrder: currentDraftOrder }) => + Boolean( + currentDraftOrder && + values.contactEmail?.trim() && + (currentDraftOrder.shipping?.email !== + values.contactEmail.trim() || + currentDraftOrder.billing?.email !== values.contactEmail.trim()) + ), + buildPatch: ({ values, draftOrder: currentDraftOrder }) => { + const email = values.contactEmail?.trim(); + if (!email || !currentDraftOrder) return null; - const [email] = useDebouncedValue(contactEmail, { - wait: 1000, - }); - - useDraftOrderFieldSync({ - key: 'email', - data: email, - deps: [email, emailHasChanged, draftOrder], - enabled: - emailHasChanged && - email?.trim() && - email === contactEmail && - !!draftOrder, + return { + ...(currentDraftOrder.shipping?.email !== email + ? { shipping: { email } } + : {}), + ...(currentDraftOrder.billing?.email !== email + ? { billing: { email } } + : {}), + }; + }, + }), + [] + ) + ); + useDraftOrderFieldDirtyMarker({ + id: 'contact-email', fieldNames: ['contactEmail'], - mapToInput: emailValue => { - if (!draftOrder) return {}; - - const shippingIsDifferent = draftOrder?.shipping?.email !== emailValue; - const billingIsDifferent = draftOrder?.billing?.email !== emailValue; - - return { - ...(shippingIsDifferent - ? { shipping: { email: emailValue?.trim() } } - : {}), - ...(billingIsDifferent - ? { billing: { email: emailValue?.trim() } } - : {}), - }; - }, + disabled: isConfirmingCheckout, }); return ( diff --git a/packages/react/src/components/checkout/contact/phone-input.tsx b/packages/react/src/components/checkout/contact/phone-input.tsx index 35b44446..7f3c67f9 100644 --- a/packages/react/src/components/checkout/contact/phone-input.tsx +++ b/packages/react/src/components/checkout/contact/phone-input.tsx @@ -1,6 +1,5 @@ 'use client'; -import { useDebouncedValue } from '@tanstack/react-pacer'; import { CheckIcon, ChevronsUpDown } from 'lucide-react'; import React from 'react'; import { useFormContext } from 'react-hook-form'; @@ -10,7 +9,10 @@ import { checkIsValidPhone } from '@/components/checkout/address/utils/check-is- import { mapAddressFieldsToInput } from '@/components/checkout/address/utils/map-address-fields-to-input'; import { useCheckoutContext } from '@/components/checkout/checkout'; import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import { useDraftOrderFieldSync } from '@/components/checkout/order/use-draft-order-sync'; +import { + useDraftOrderFieldDirtyMarker, + useRegisterDraftOrderFieldSync, +} from '@/components/checkout/order/use-draft-order-sync'; import { Button } from '@/components/ui/button'; import { Command, @@ -212,13 +214,6 @@ export function PhoneInput({ const { session, requiredFields } = useCheckoutContext(); const { data: draftOrder } = useDraftOrder(); - const phoneValue = form.watch(`${sectionKey}Phone`); - const useShippingAddress = form.watch('paymentUseShippingAddress'); - - const [phone] = useDebouncedValue(phoneValue, { - wait: 1000, - }); - const section = sectionKey === 'shipping' ? draftOrder?.shipping : draftOrder?.billing; @@ -227,33 +222,54 @@ export function PhoneInput({ session?.shipping?.originAddress?.countryCode || 'US'; - const isValidPhone = React.useMemo(() => checkIsValidPhone(phone), [phone]); + const phoneFieldName = `${sectionKey}Phone`; + const registrationId = `${sectionKey}-phone`; + + useRegisterDraftOrderFieldSync( + React.useMemo( + () => ({ + id: registrationId, + fieldNames: [phoneFieldName], + debounceMs: 1000, + enabled: ({ values, draftOrder: currentDraftOrder }) => { + if (!session?.enablePhoneCollection || !currentDraftOrder) { + return false; + } - // Check if phone value differs from order value - const phoneHasChanged = React.useMemo(() => { - if (!draftOrder) return true; // If no order, allow sync - const orderSection = - sectionKey === 'shipping' ? draftOrder.shipping : draftOrder.billing; - return (orderSection?.phone || '') !== (phone || ''); - }, [draftOrder, sectionKey, phone]); + const phone = String( + values[phoneFieldName as keyof typeof values] ?? '' + ); + const orderSection = + sectionKey === 'shipping' + ? currentDraftOrder.shipping + : currentDraftOrder.billing; - useDraftOrderFieldSync({ - key: 'phone', - data: phone, - deps: [phone, isValidPhone], - enabled: - phoneHasChanged && // Only sync if values differ from order - phone === phoneValue && - (phone - ? isValidPhone && phone?.trim() !== '' - : !phone && phoneValue === ''), - fieldNames: [`${sectionKey}Phone`], - mapToInput: data => - mapAddressFieldsToInput( - { phone: data }, - sectionKey as 'shipping' | 'billing', - useShippingAddress - ), + if ((orderSection?.phone || '') === (phone || '')) return false; + return phone ? checkIsValidPhone(phone) && phone.trim() !== '' : true; + }, + buildPatch: ({ values }) => { + const phone = String( + values[phoneFieldName as keyof typeof values] ?? '' + ); + return mapAddressFieldsToInput( + { phone }, + sectionKey as 'shipping' | 'billing', + Boolean(values.paymentUseShippingAddress) + ); + }, + }), + [ + phoneFieldName, + registrationId, + sectionKey, + session?.enablePhoneCollection, + ] + ) + ); + useDraftOrderFieldDirtyMarker({ + id: registrationId, + fieldNames: [phoneFieldName], + disabled, }); return session?.enablePhoneCollection ? ( diff --git a/packages/react/src/components/checkout/notes/notes-form.tsx b/packages/react/src/components/checkout/notes/notes-form.tsx index 320877d9..6442dbb3 100644 --- a/packages/react/src/components/checkout/notes/notes-form.tsx +++ b/packages/react/src/components/checkout/notes/notes-form.tsx @@ -4,8 +4,10 @@ import { useDebouncedValue } from '@tanstack/react-pacer'; import React from 'react'; import { useFormContext } from 'react-hook-form'; import { useCheckoutContext } from '@/components/checkout/checkout'; -import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import { useDraftOrderFieldSync } from '@/components/checkout/order/use-draft-order-sync'; +import { + useDraftOrderFieldDirtyMarker, + useRegisterDraftOrderFieldSync, +} from '@/components/checkout/order/use-draft-order-sync'; import { FormField, FormItem, @@ -21,8 +23,6 @@ export function NotesForm() { const form = useFormContext(); const { t } = useGoDaddyContext(); const { isConfirmingCheckout, requiredFields } = useCheckoutContext(); - const { data: draftOrder } = useDraftOrder(); - const notesField = form.watch('notes'); const [notes] = useDebouncedValue(notesField, { @@ -43,32 +43,37 @@ export function NotesForm() { } }, [notes]); - // Check if notes value differs from order value - const notesHasChanged = React.useMemo(() => { - if (!draftOrder) return true; // If no order, allow sync - const orderNotes = - draftOrder.notes?.find(note => note.authorType === 'CUSTOMER')?.content || - ''; - return orderNotes !== (notes || ''); - }, [draftOrder, notes]); - - useDraftOrderFieldSync({ - key: 'notes', - data: notes, - deps: [notes, notesHasChanged], - enabled: notesHasChanged, + useRegisterDraftOrderFieldSync( + React.useMemo( + () => ({ + id: 'notes', + fieldNames: ['notes'], + debounceMs: 1000, + enabled: ({ values, draftOrder }) => { + if (!draftOrder) return false; + const orderNotes = + draftOrder.notes?.find(note => note.authorType === 'CUSTOMER') + ?.content || ''; + return orderNotes !== (values.notes || ''); + }, + buildPatch: ({ values }) => ({ + notes: values.notes?.trim() + ? [ + { + authorType: 'CUSTOMER', + content: values.notes.trim(), + }, + ] + : null, + }), + }), + [] + ) + ); + useDraftOrderFieldDirtyMarker({ + id: 'notes', fieldNames: ['notes'], - preserveFormData: false, - mapToInput: notesValue => ({ - notes: notesValue?.trim() - ? [ - { - authorType: 'CUSTOMER', - content: notesValue.trim(), - }, - ] - : null, - }), + disabled: isConfirmingCheckout, }); return ( diff --git a/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx b/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx index 4f64cf7d..4e60df70 100644 --- a/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx +++ b/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx @@ -28,13 +28,44 @@ import { import { getLastUpdateInput } from '../__tests__/checkout-test-fixtures'; function SyncConsumer() { - const { enqueueDraftOrderPatch, flushDraftOrderSync } = - useDraftOrderSyncQueue(); + const { + enqueueDraftOrderPatch, + flushDraftOrderSync, + markDraftOrderSyncDirty, + registerDraftOrderSync, + } = useDraftOrderSyncQueue(); const form = useFormContext(); + React.useEffect( + () => + registerDraftOrderSync({ + id: 'shipping-name', + fieldNames: ['shippingFirstName', 'shippingLastName'], + debounceMs: 100, + enabled: ({ values, draftOrder }) => + Boolean( + draftOrder && + values.shippingFirstName?.trim() && + values.shippingLastName?.trim() && + ((draftOrder.shipping?.firstName || '') !== + values.shippingFirstName || + (draftOrder.shipping?.lastName || '') !== + values.shippingLastName) + ), + buildPatch: ({ values }) => ({ + shipping: { + firstName: values.shippingFirstName.trim(), + lastName: values.shippingLastName.trim(), + }, + }), + }), + [registerDraftOrderSync] + ); + return (
+ {String(!!form.formState.dirtyFields.shippingFirstName)} @@ -74,6 +105,35 @@ function SyncConsumer() { + + +
); } @@ -249,6 +309,76 @@ describe('DraftOrderSyncProvider integration', () => { }); }); + it('debounces dirty registrations and builds the patch from current form values', async () => { + const { user } = renderSyncHarness(); + + await user.clear(screen.getByLabelText('first name')); + await user.type(screen.getByLabelText('first name'), 'Registered'); + await user.clear(screen.getByLabelText('last name')); + await user.type(screen.getByLabelText('last name'), 'Buyer'); + await user.click( + screen.getByRole('button', { name: 'mark-shipping-name' }) + ); + await advance(100); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + shipping: { firstName: 'Registered', lastName: 'Buyer' }, + }); + }); + + it('includeCurrentValues flushes registered values even before a dirty mark', async () => { + const { user } = renderSyncHarness(); + + await user.clear(screen.getByLabelText('first name')); + await user.type(screen.getByLabelText('first name'), 'Current'); + await user.click( + screen.getByRole('button', { name: 'flush-current-values' }) + ); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + shipping: { firstName: 'Current', lastName: 'Buyer' }, + }); + expect(getOperations('DraftOrder').length).toBeGreaterThan(0); + }); + + it('skips registration patches while confirming unless the flush is the final sync', async () => { + const { user } = renderSyncHarness({ isConfirmingCheckout: true }); + + await user.click( + screen.getByRole('button', { name: 'flush-current-values' }) + ); + await flushPromises(); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + + await user.click(screen.getByRole('button', { name: 'flush-final' })); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + shipping: { firstName: 'Initial', lastName: 'Buyer' }, + }); + }); + + it('sends queued patches on the final sync even after confirmation started', async () => { + const { user } = renderSyncHarness(); + + await user.click(screen.getByRole('button', { name: 'enqueue-a' })); + await user.click(screen.getByRole('button', { name: 'start-confirming' })); + await advance(100); + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + + await user.click(screen.getByRole('button', { name: 'flush-final' })); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect( + getOperations('UpdateCheckoutSessionDraftOrder')[0].input + ).toMatchObject({ + shipping: { firstName: 'Alpha' }, + }); + }); + it('ignores newly queued patches after checkout confirmation starts', async () => { const { user } = renderSyncHarness({ isConfirmingCheckout: true }); diff --git a/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx b/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx index c6b0d231..d10c9b40 100644 --- a/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx +++ b/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx @@ -1,18 +1,65 @@ +import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; -import { useFormContext } from 'react-hook-form'; +import { type UseFormReturn, useFormContext } from 'react-hook-form'; import { type CheckoutFormData, useCheckoutContext, } from '@/components/checkout/checkout'; +import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useUpdateOrder } from '@/components/checkout/order/use-update-order'; -import type { UpdateDraftOrderInput } from '@/types'; +import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; +import type { + CheckoutSession, + DraftOrder, + UpdateDraftOrderInput, +} from '@/types'; export type DraftOrderPatch = Omit; +type DraftOrderSyncRegistrationId = string; + +type DraftOrderSyncRegistrationContext = { + values: CheckoutFormData; + form: UseFormReturn; + draftOrder?: DraftOrder | null; + session?: CheckoutSession | null; +}; + +export type DraftOrderSyncRegistration = { + id: DraftOrderSyncRegistrationId; + fieldNames: string[]; + debounceMs?: number; + /** Skips the registration when its values are unchanged or not yet valid. */ + enabled?: (context: DraftOrderSyncRegistrationContext) => boolean; + buildPatch: ( + context: DraftOrderSyncRegistrationContext + ) => DraftOrderPatch | null; +}; + interface EnqueueDraftOrderPatchOptions { fieldNames?: string[]; debounceMs?: number; immediate?: boolean; + allowWhileConfirming?: boolean; +} + +export interface FlushDraftOrderSyncOptions { + /** + * Rebuild patches from every registration using the current form values + * instead of only the registrations marked dirty by background edits. + */ + includeCurrentValues?: boolean; + refetchLatestOrder?: boolean; + /** + * Registration patches are not rebuilt while checkout is confirming unless + * the caller is the authoritative final checkout sync. + */ + allowWhileConfirming?: boolean; +} + +export interface FlushDraftOrderSyncResult { + latestOrder?: DraftOrder | null; + patchSent: boolean; } interface DraftOrderSyncContextValue { @@ -20,7 +67,16 @@ interface DraftOrderSyncContextValue { patch: DraftOrderPatch, options?: EnqueueDraftOrderPatchOptions ) => void; - flushDraftOrderSync: () => Promise; + registerDraftOrderSync: ( + registration: DraftOrderSyncRegistration + ) => () => void; + markDraftOrderSyncDirty: ( + registrationId: DraftOrderSyncRegistrationId, + options?: { immediate?: boolean; allowWhileConfirming?: boolean } + ) => void; + flushDraftOrderSync: ( + options?: FlushDraftOrderSyncOptions + ) => Promise; } const DraftOrderSyncContext = @@ -30,6 +86,15 @@ function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } +function hasActualPatchContent(patch: DraftOrderPatch | null | undefined) { + if (!patch) return false; + + return Object.entries(patch).some( + ([inputKey, value]) => + inputKey !== 'context' && inputKey !== 'customerId' && value !== undefined + ); +} + export function mergeDraftOrderPatch( base: T | null | undefined, patch: T @@ -78,14 +143,20 @@ export function DraftOrderSyncProvider({ children: React.ReactNode; }) { const updateDraftOrder = useUpdateOrder(); + const queryClient = useQueryClient(); const { session, isConfirmingCheckout } = useCheckoutContext(); const form = useFormContext(); + const draftOrderQuery = useDraftOrder(); + const registrationsRef = React.useRef< + Map + >(new Map()); + const dirtyRegistrationIdsRef = React.useRef>(new Set()); const pendingPatchRef = React.useRef(null); const pendingFieldNamesRef = React.useRef>(new Set()); + const pendingRegistrationIdsRef = React.useRef>(new Set()); const timerRef = React.useRef | null>(null); const inFlightRef = React.useRef(false); - const drainPromiseRef = React.useRef | null>(null); - const idleWaitersRef = React.useRef void>>([]); + const drainPromiseRef = React.useRef | null>(null); const clearTimer = React.useCallback(() => { if (timerRef.current) { @@ -94,23 +165,59 @@ export function DraftOrderSyncProvider({ } }, []); - const resolveIdleWaiters = React.useCallback(() => { - if (timerRef.current || pendingPatchRef.current || inFlightRef.current) { - return; - } + const getCurrentDraftOrder = React.useCallback(() => { + if (draftOrderQuery.data) return draftOrderQuery.data; - const waiters = idleWaitersRef.current; - idleWaitersRef.current = []; - for (const resolve of waiters) { - resolve(); + if (session?.id) { + const cached = queryClient.getQueryData<{ + checkoutSession?: { draftOrder?: DraftOrder | null }; + }>(checkoutQueryKeys.draftOrder(session.id)); + if (cached?.checkoutSession?.draftOrder) { + return cached.checkoutSession.draftOrder; + } } - }, []); + + return null; + }, [draftOrderQuery.data, queryClient, session]); + + const refetchLatestDraftOrder = React.useCallback(async () => { + if (!session?.id) return getCurrentDraftOrder(); + + // `throwOnError` keeps a failed in-confirm fetch from silently falling back + // to a stale cached order that confirmation guards would then trust. + const result = await draftOrderQuery.refetch({ throwOnError: true }); + return result.data ?? getCurrentDraftOrder(); + }, [draftOrderQuery, getCurrentDraftOrder, session?.id]); + + const queuePatch = React.useCallback( + ( + patch: DraftOrderPatch, + fieldNames: string[] = [], + registrationIds: string[] = [] + ) => { + pendingPatchRef.current = mergeDraftOrderPatch( + pendingPatchRef.current, + patch + ); + + for (const fieldName of fieldNames) { + pendingFieldNamesRef.current.add(fieldName); + } + + for (const registrationId of registrationIds) { + pendingRegistrationIdsRef.current.add(registrationId); + } + }, + [] + ); const drainQueue = React.useCallback(async () => { if (drainPromiseRef.current) return drainPromiseRef.current; drainPromiseRef.current = (async () => { - if (inFlightRef.current) return; + if (inFlightRef.current) return false; + + let patchSent = false; while (pendingPatchRef.current) { const patch = pendingPatchRef.current; @@ -118,18 +225,22 @@ export function DraftOrderSyncProvider({ if (!session) { pendingPatchRef.current = null; pendingFieldNamesRef.current = new Set(); + pendingRegistrationIdsRef.current = new Set(); break; } const { channelId, storeId, draftOrder, customerId } = session; if (!channelId || !storeId || !draftOrder?.id) { pendingPatchRef.current = null; pendingFieldNamesRef.current = new Set(); + pendingRegistrationIdsRef.current = new Set(); break; } const fieldNames = [...pendingFieldNamesRef.current]; + const registrationIds = [...pendingRegistrationIdsRef.current]; pendingPatchRef.current = null; pendingFieldNamesRef.current = new Set(); + pendingRegistrationIdsRef.current = new Set(); inFlightRef.current = true; try { @@ -140,6 +251,7 @@ export function DraftOrderSyncProvider({ ...(customerId ? { customerId } : {}), }, }); + patchSent = true; for (const fieldName of fieldNames) { const currentValue = form.getValues( @@ -156,36 +268,147 @@ export function DraftOrderSyncProvider({ for (const fieldName of fieldNames) { pendingFieldNamesRef.current.add(fieldName); } + for (const registrationId of registrationIds) { + pendingRegistrationIdsRef.current.add(registrationId); + dirtyRegistrationIdsRef.current.add(registrationId); + } throw error; } finally { inFlightRef.current = false; } } - resolveIdleWaiters(); + return patchSent; })(); try { - await drainPromiseRef.current; + return await drainPromiseRef.current; } finally { drainPromiseRef.current = null; - resolveIdleWaiters(); } - }, [form, resolveIdleWaiters, session, updateDraftOrder]); + }, [form, session, updateDraftOrder]); + + const buildPatchFromRegistrations = React.useCallback( + (ids: string[], draftOrder?: DraftOrder | null) => { + const values = form.getValues(); + const context: DraftOrderSyncRegistrationContext = { + values, + form, + draftOrder, + session, + }; + let patch: DraftOrderPatch | null = null; + const fieldNames = new Set(); + const registrationIds = new Set(); + + for (const id of ids) { + const registration = registrationsRef.current.get(id); + if (!registration) continue; + if (registration.enabled?.(context) === false) continue; + + const registrationPatch = registration.buildPatch(context); + if (!hasActualPatchContent(registrationPatch)) continue; + + patch = mergeDraftOrderPatch( + patch, + registrationPatch as DraftOrderPatch + ); + registrationIds.add(id); + for (const fieldName of registration.fieldNames) { + fieldNames.add(fieldName); + } + } - const enqueueDraftOrderPatch = React.useCallback( - (patch: DraftOrderPatch, options: EnqueueDraftOrderPatchOptions = {}) => { - if (isConfirmingCheckout) return; + return { + patch, + fieldNames: [...fieldNames], + registrationIds: [...registrationIds], + }; + }, + [form, session] + ); - pendingPatchRef.current = mergeDraftOrderPatch( - pendingPatchRef.current, - patch - ); + const flushDraftOrderSync = React.useCallback( + async ( + options: FlushDraftOrderSyncOptions = {} + ): Promise => { + clearTimer(); - for (const fieldName of options.fieldNames ?? []) { - pendingFieldNamesRef.current.add(fieldName); + let patchSent = await drainQueue(); + let latestBeforePatch = options.includeCurrentValues + ? await refetchLatestDraftOrder() + : getCurrentDraftOrder(); + const canBuildRegistrationPatches = + !isConfirmingCheckout || Boolean(options.allowWhileConfirming); + let ids: string[] = []; + + if (canBuildRegistrationPatches) { + ids = options.includeCurrentValues + ? [...registrationsRef.current.keys()] + : [...dirtyRegistrationIdsRef.current]; + } + + if (ids.length) { + latestBeforePatch ??= await refetchLatestDraftOrder(); + const { patch, fieldNames, registrationIds } = + buildPatchFromRegistrations(ids, latestBeforePatch); + + if (patch) { + for (const registrationId of registrationIds) { + dirtyRegistrationIdsRef.current.delete(registrationId); + } + queuePatch(patch, fieldNames, registrationIds); + patchSent = (await drainQueue()) || patchSent; + } } + const latestAfterPatch = + options.refetchLatestOrder && patchSent + ? await refetchLatestDraftOrder() + : undefined; + + return { + latestOrder: latestAfterPatch ?? latestBeforePatch, + patchSent, + }; + }, + [ + buildPatchFromRegistrations, + clearTimer, + drainQueue, + getCurrentDraftOrder, + isConfirmingCheckout, + queuePatch, + refetchLatestDraftOrder, + ] + ); + + const scheduleDebouncedFlush = React.useCallback(() => { + clearTimer(); + + const debounceMs = [...dirtyRegistrationIdsRef.current].reduce( + (delay, registrationId) => { + const registration = registrationsRef.current.get(registrationId); + return Math.max(delay, registration?.debounceMs ?? 750); + }, + 0 + ); + + timerRef.current = setTimeout(() => { + timerRef.current = null; + void flushDraftOrderSync().catch(() => { + // The failed patch is restored in drainQueue's catch block. Ignore + // background sync failures here so payment/explicit flush paths can + // surface the recoverable error to the customer. + }); + }, debounceMs || 750); + }, [clearTimer, flushDraftOrderSync]); + + const enqueueDraftOrderPatch = React.useCallback( + (patch: DraftOrderPatch, options: EnqueueDraftOrderPatchOptions = {}) => { + if (isConfirmingCheckout && !options.allowWhileConfirming) return; + + queuePatch(patch, options.fieldNames); clearTimer(); const drainQueueSafely = () => { @@ -206,36 +429,69 @@ export function DraftOrderSyncProvider({ drainQueueSafely(); }, options.debounceMs ?? 750); }, - [clearTimer, drainQueue, isConfirmingCheckout] + [clearTimer, drainQueue, isConfirmingCheckout, queuePatch] ); - const flushDraftOrderSync = React.useCallback(async () => { - clearTimer(); - await drainQueue(); + const registerDraftOrderSync = React.useCallback( + (registration: DraftOrderSyncRegistration) => { + registrationsRef.current.set(registration.id, registration); - if (!timerRef.current && !pendingPatchRef.current && !inFlightRef.current) { - return; - } + return () => { + const current = registrationsRef.current.get(registration.id); + if (current === registration) { + registrationsRef.current.delete(registration.id); + dirtyRegistrationIdsRef.current.delete(registration.id); + } + }; + }, + [] + ); - await new Promise(resolve => { - idleWaitersRef.current.push(resolve); - resolveIdleWaiters(); - }); - }, [clearTimer, drainQueue, resolveIdleWaiters]); + const markDraftOrderSyncDirty = React.useCallback( + ( + registrationId: DraftOrderSyncRegistrationId, + options: { immediate?: boolean; allowWhileConfirming?: boolean } = {} + ) => { + if (isConfirmingCheckout && !options.allowWhileConfirming) return; + if (!registrationsRef.current.has(registrationId)) return; + + dirtyRegistrationIdsRef.current.add(registrationId); + + if (options.immediate) { + void flushDraftOrderSync({ + allowWhileConfirming: options.allowWhileConfirming, + }).catch(() => { + // Explicit callers can await flushDraftOrderSync directly when they + // need errors. Dirty marks are background sync triggers. + }); + return; + } + + scheduleDebouncedFlush(); + }, + [flushDraftOrderSync, isConfirmingCheckout, scheduleDebouncedFlush] + ); React.useEffect(() => { if (!isConfirmingCheckout) return; - clearTimer(); - pendingPatchRef.current = null; - pendingFieldNamesRef.current = new Set(); }, [clearTimer, isConfirmingCheckout]); React.useEffect(() => clearTimer, [clearTimer]); const value = React.useMemo( - () => ({ enqueueDraftOrderPatch, flushDraftOrderSync }), - [enqueueDraftOrderPatch, flushDraftOrderSync] + () => ({ + enqueueDraftOrderPatch, + flushDraftOrderSync, + markDraftOrderSyncDirty, + registerDraftOrderSync, + }), + [ + enqueueDraftOrderPatch, + flushDraftOrderSync, + markDraftOrderSyncDirty, + registerDraftOrderSync, + ] ); return ( diff --git a/packages/react/src/components/checkout/order/use-draft-order-sync.ts b/packages/react/src/components/checkout/order/use-draft-order-sync.ts index 58d42650..b0004271 100644 --- a/packages/react/src/components/checkout/order/use-draft-order-sync.ts +++ b/packages/react/src/components/checkout/order/use-draft-order-sync.ts @@ -1,126 +1,49 @@ import * as React from 'react'; -import { type UseFormReturn, useFormContext } from 'react-hook-form'; +import { useFormContext, useWatch } from 'react-hook-form'; import { - type CheckoutFormData, - useCheckoutContext, -} from '@/components/checkout/checkout'; -import { useDraftOrderSyncQueue } from '@/components/checkout/order/draft-order-sync-provider'; -import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; -import type { DraftOrder, UpdateDraftOrderInput } from '@/types'; - -// Helper function to merge updates while preserving existing behavior around -// copying explicit shipping patches to billing when the user has selected -// "use shipping address as billing address". -function mergeWithExistingFormData( - updates: Omit, - form: UseFormReturn, - draftOrder: DraftOrder | null | undefined, - preserveFormData = true -): Omit { - if (!preserveFormData || !draftOrder) return updates; - - const useShippingAddress = form.getValues('paymentUseShippingAddress'); - const result = { ...updates }; - - // If updating shipping, only include changed shipping data - if (updates.shipping) { - // Only include explicit shipping updates. Other form values may be newer than - // the cached draft order but belong to separate sync hooks; merging all of - // them here can re-send stale defaults after checkout confirmation/refetches. - result.shipping = updates.shipping; - - // If paymentUseShippingAddress is true, also update billing with the same - // explicit shipping patch, not the entire current shipping form snapshot. - if (useShippingAddress && result.shipping) { - result.billing = { - ...result.shipping, - }; - } - } - - // If updating billing, only include changed billing data - if (updates.billing && !useShippingAddress) { - // Only include explicit billing updates. This avoids a names-only billing - // form syncing hidden/stale billing address fields. - result.billing = updates.billing; - } - - return result; + type DraftOrderSyncRegistration, + useDraftOrderSyncQueue, +} from '@/components/checkout/order/draft-order-sync-provider'; + +/** + * Registers how a form section maps its current values to a draft-order patch. + * The provider owns when that patch is built and sent, which lets the final + * pre-confirmation sync rebuild every registered patch from the latest form + * values instead of relying on debounced background effects. + */ +export function useRegisterDraftOrderFieldSync( + registration: DraftOrderSyncRegistration +) { + const { registerDraftOrderSync } = useDraftOrderSyncQueue(); + + React.useEffect( + () => registerDraftOrderSync(registration), + [registerDraftOrderSync, registration] + ); } -export function useDraftOrderFieldSync({ - data, - deps, - enabled, - mapToInput, - key, +/** Flags a registration for debounced background sync when its fields change. */ +export function useDraftOrderFieldDirtyMarker({ + id, fieldNames, - preserveFormData = true, + disabled, }: { - data: T; - deps: React.DependencyList; - enabled: boolean; - mapToInput: (data: T) => Omit; - key: string; - fieldNames?: string[]; - preserveFormData?: boolean; + id: string; + fieldNames: string[]; + disabled?: boolean; }) { - const lastSubmittedRef = React.useRef>({}); - const { enqueueDraftOrderPatch } = useDraftOrderSyncQueue(); - const { session, isConfirmingCheckout } = useCheckoutContext(); - const { data: draftOrderData } = useDraftOrder(); - const form = useFormContext(); - - React.useEffect(() => { - if (!enabled || isConfirmingCheckout) return; - - const memoKey = key ?? 'default'; - const currentSerialized = JSON.stringify(data); - - const hasChanged = lastSubmittedRef.current[memoKey] !== currentSerialized; - - if (!hasChanged) return; - - lastSubmittedRef.current[memoKey] = currentSerialized; - - if (!session) return; - const { channelId, storeId } = session; - if (!channelId || !storeId || !draftOrderData?.id) return; - - const rawInput = mapToInput(data); - const input = mergeWithExistingFormData( - rawInput, - form, - draftOrderData, - preserveFormData - ); - - // Don't sync if input is effectively empty (only contains context/customerId) - const hasActualContent = Object.entries(input).some( - ([inputKey, value]) => - inputKey !== 'context' && - inputKey !== 'customerId' && - value !== undefined - ); - - if (!hasActualContent) return; - - enqueueDraftOrderPatch(input, { - fieldNames, - debounceMs: 1000, - }); - }, [ - isConfirmingCheckout, - enabled, - data, - mapToInput, - key, - enqueueDraftOrderPatch, - session, - form, - fieldNames, - preserveFormData, - draftOrderData, - ...deps, - ]); + const { control } = useFormContext(); + const { markDraftOrderSyncDirty } = useDraftOrderSyncQueue(); + const values = useWatch({ control, name: fieldNames }); + const snapshot = JSON.stringify(values ?? null); + const previousSnapshotRef = React.useRef(snapshot); + + React.useLayoutEffect(() => { + if (previousSnapshotRef.current === snapshot) return; + previousSnapshotRef.current = snapshot; + + if (!disabled) { + markDraftOrderSyncDirty(id); + } + }, [disabled, id, markDraftOrderSyncDirty, snapshot]); } 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 8f54baff..8b6c6e99 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 @@ -1,4 +1,4 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation } from '@tanstack/react-query'; import { useRef } from 'react'; import { useFormContext } from 'react-hook-form'; import { @@ -6,15 +6,11 @@ import { useCheckoutContext, } from '@/components/checkout/checkout'; import { DeliveryMethods } from '@/components/checkout/delivery/delivery-methods'; -import { - type DraftOrderSession, - useDraftOrder, -} from '@/components/checkout/order/use-draft-order'; +import { useDraftOrder } from '@/components/checkout/order/use-draft-order'; import { useFlushCheckoutSync } from '@/components/checkout/payment/utils/use-flush-checkout-sync'; import { buildPickupPayload } from '@/components/checkout/pickup/utils/build-pickup-payload'; import { getPickupMode } from '@/components/checkout/pickup/utils/generate-pickup-time-slots'; import { getShippingFulfillmentSyncKey } from '@/components/checkout/shipping/utils/should-apply-shipping-method'; -import { checkoutQueryKeys } from '@/components/checkout/utils/query-keys'; import { useGoDaddyContext } from '@/godaddy-provider'; import { confirmCheckout } from '@/lib/godaddy/godaddy'; import { eventIds } from '@/tracking/events'; @@ -98,7 +94,6 @@ export function useConfirmCheckout() { const { apiHost } = useGoDaddyContext(); const form = useFormContext(); const { data: order } = useDraftOrder(); - const queryClient = useQueryClient(); const flushCheckoutSync = useFlushCheckoutSync(); const isPendingRef = useRef(false); @@ -126,7 +121,12 @@ export function useConfirmCheckout() { try { const { isExpress, ...confirmCheckoutInput } = input; - await flushCheckoutSync(); + setCheckoutErrors(undefined); + setIsConfirmingCheckout(true); + + const { latestOrder } = await flushCheckoutSync({ + includeCurrentFormDiff: true, + }); const deliveryMethod = form.getValues('deliveryMethod'); const isPickup = @@ -134,22 +134,12 @@ export function useConfirmCheckout() { const isShipping = deliveryMethod === DeliveryMethods.SHIP && !isExpress; - const latestDraftOrderSession = session?.id - ? await queryClient - .fetchQuery({ - queryKey: checkoutQueryKeys.draftOrder(session.id), - }) - .catch(error => { - setCheckoutErrors(['DRAFT_ORDER_UPDATE_FAILED']); - throw error; - }) - : undefined; - const latestOrder = - latestDraftOrderSession?.checkoutSession?.draftOrder ?? order; + const latestDraftOrder = latestOrder ?? order; - const hasShippingLines = (latestOrder?.shippingLines?.length ?? 0) > 0; + const hasShippingLines = + (latestDraftOrder?.shippingLines?.length ?? 0) > 0; const hasLineItemsMissingShippingFulfillment = Boolean( - getShippingFulfillmentSyncKey(latestOrder?.lineItems) + getShippingFulfillmentSyncKey(latestDraftOrder?.lineItems) ); if ( @@ -190,9 +180,6 @@ export function useConfirmCheckout() { // pickUpData, // }); - setCheckoutErrors(undefined); - setIsConfirmingCheckout(true); - track({ eventId: eventIds.paymentStart, type: TrackingEventType.EVENT, diff --git a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts index 35235cd9..634d5de2 100644 --- a/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts +++ b/packages/react/src/components/checkout/payment/utils/use-flush-checkout-sync.ts @@ -14,6 +14,7 @@ const CHECKOUT_SYNC_ERROR = 'DRAFT_ORDER_UPDATE_FAILED'; interface FlushCheckoutSyncOptions { timeoutMs?: number; includeFetches?: boolean; + includeCurrentFormDiff?: boolean; } function delay(ms: number) { @@ -28,8 +29,6 @@ export function useFlushCheckoutSync() { return React.useCallback( async (options: FlushCheckoutSyncOptions = {}) => { try { - await flushDraftOrderSync(); - const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS; const includeFetches = options.includeFetches ?? true; const startedAt = Date.now(); @@ -58,31 +57,51 @@ export function useFlushCheckoutSync() { : []), ]; - while (true) { - const pendingMutations = criticalMutationKeys.reduce( - (count, mutationKey) => - count + queryClient.isMutating({ mutationKey }), - 0 - ); - - const pendingFetches = includeFetches - ? criticalQueryKeys.reduce( - (count, queryKey) => - count + queryClient.isFetching({ queryKey }), - 0 - ) - : 0; - - if (pendingMutations === 0 && pendingFetches === 0) { - return; - } + const waitForCriticalWork = async () => { + while (true) { + const pendingMutations = criticalMutationKeys.reduce( + (count, mutationKey) => + count + queryClient.isMutating({ mutationKey }), + 0 + ); + + const pendingFetches = includeFetches + ? criticalQueryKeys.reduce( + (count, queryKey) => + count + queryClient.isFetching({ queryKey }), + 0 + ) + : 0; + + if (pendingMutations === 0 && pendingFetches === 0) return; - if (Date.now() - startedAt > timeoutMs) { - throw new Error('Timed out waiting for checkout sync to settle'); + if (Date.now() - startedAt > timeoutMs) { + throw new Error('Timed out waiting for checkout sync to settle'); + } + + await delay(POLL_INTERVAL_MS); } + }; - await delay(POLL_INTERVAL_MS); + if (!options.includeCurrentFormDiff) { + const result = await flushDraftOrderSync(); + await waitForCriticalWork(); + return result; } + + // Drain queued work and let critical checkout mutations/fetches settle + // first, so the final diff compares against post-settle backend state. + await flushDraftOrderSync(); + await waitForCriticalWork(); + + const result = await flushDraftOrderSync({ + includeCurrentValues: true, + refetchLatestOrder: true, + allowWhileConfirming: true, + }); + await waitForCriticalWork(); + + return result; } catch (error) { setCheckoutErrors([CHECKOUT_SYNC_ERROR]); throw error; From bc32f151a66a5aeec5868b29a175ed31af1a179a Mon Sep 17 00:00:00 2001 From: Phil Bennett Date: Thu, 6 Aug 2026 14:57:19 -0500 Subject: [PATCH 2/2] fix custom schema validations --- .../checkout-draft-order-sync.test.tsx | 66 +++++++ .../checkout-form-validation.test.tsx | 168 ++++++++++++++++++ .../checkout/address/address-form.tsx | 12 +- .../address/utils/check-is-valid-phone.ts | 5 +- .../src/components/checkout/checkout.tsx | 11 ++ .../checkout/contact/contact-form.tsx | 17 +- .../checkout/contact/phone-input.tsx | 10 +- .../checkout/form/checkout-form.tsx | 2 +- .../checkout/form/custom-form-provider.tsx | 128 +++++++++++-- ...t-order-sync-provider.integration.test.tsx | 29 +++ .../order/draft-order-sync-provider.tsx | 79 +++++++- 11 files changed, 492 insertions(+), 35 deletions(-) diff --git a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx index 7bb7bd4f..a5a8a11b 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-draft-order-sync.test.tsx @@ -445,6 +445,45 @@ describe('Checkout draft-order field sync', () => { expect(getLastUpdateInput()?.billing).not.toHaveProperty('lastName'); }); + it('keeps a partial shipping name dirty after address-only sync refetches the order', async () => { + const { user } = renderCheckout({ + draftOrderOverrides: { + shipping: { + firstName: '', + lastName: '', + address: buildShippingAddress({ + addressLine1: '', + addressLine2: '', + adminArea1: 'GA', + adminArea2: '', + postalCode: '', + countryCode: 'US', + }), + }, + billing: { + firstName: '', + lastName: '', + address: null, + }, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await typeIntoNamedField(user, 'shippingFirstName', 'Partial'); + await typeIntoNamedField(user, 'shippingAddressLine1', '456 Shipping Ln'); + await typeIntoNamedField(user, 'shippingAdminArea2', 'Jasper'); + await typeIntoNamedField(user, 'shippingPostalCode', '30143'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + expect(getLastUpdateInput()?.shipping).not.toHaveProperty('firstName'); + await waitForOperation('DraftOrder'); + + await waitFor(() => { + expect(getNamedInput('shippingFirstName')).toHaveValue('Partial'); + }); + }); + it('syncs a complete billing address without requiring first or last name', async () => { const { user } = renderCheckout({ draftOrderOverrides: { @@ -815,6 +854,33 @@ describe('Checkout draft-order field sync', () => { expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); }); + it('does not sync an invalid email and syncs it once corrected', async () => { + const { user } = renderCheckout(); + await waitForCheckoutReady(); + await waitForOperation('ApplyCheckoutSessionShippingMethod'); + clearOperations(); + + await typeIntoNamedField(user, 'contactEmail', 'not-an-email'); + await advanceCheckoutDebounce(); + await flushPromises(); + + expect(getOperations('UpdateCheckoutSessionDraftOrder')).toHaveLength(0); + + await typeIntoNamedField(user, 'contactEmail', 'valid@example.com'); + await advanceCheckoutDebounce(); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + + expect(getLastUpdateInput()).toMatchObject({ + shipping: { email: 'valid@example.com' }, + billing: { email: 'valid@example.com' }, + }); + expect( + getOperations('UpdateCheckoutSessionDraftOrder').map(operation => + JSON.stringify(operation.input) + ) + ).not.toContain(expect.stringContaining('not-an-email')); + }); + it('resetField after a successful sync makes the typed value pristine for later refetches', async () => { const { user, queryClient, session } = renderCheckout({ draftOrderOverrides: { shipping: { firstName: '' } }, diff --git a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx index fa377efc..63b22d85 100644 --- a/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx +++ b/packages/react/src/components/checkout/__tests__/checkout-form-validation.test.tsx @@ -2,6 +2,7 @@ import { enUs } from '@godaddy/localizations'; import { screen, waitFor } from '@testing-library/react'; import { useFormContext } from 'react-hook-form'; import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; import { PaymentMethodType, PaymentProvider } from '@/types'; import { buildDraftOrder, @@ -163,6 +164,173 @@ describe('Checkout form validation', () => { expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); }); + it('enforces a custom checkoutFormSchema rule on a field free pickup validation skips', async () => { + const customMessage = 'Phone number is required'; + const draftOrder = makeFreePickupOrder({ + billing: { + firstName: 'Pat', + lastName: 'Pickup', + phone: '', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + billingPhone: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(document.body).toHaveTextContent(customMessage); + }); + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(0); + + await user.type(screen.getByLabelText(/phone/i), '4805551234'); + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + }); + + it('does not enforce custom phone rules when phone collection is disabled', async () => { + const customMessage = 'Phone number is required'; + const draftOrder = makeFreePickupOrder({ + billing: { + firstName: 'Pat', + lastName: 'Pickup', + phone: '', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + billingPhone: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: false, + enableLocalPickup: true, + enableTaxCollection: false, + enablePhoneCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + expect(screen.queryByLabelText(/phone/i)).not.toBeInTheDocument(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + expect(document.body).not.toHaveTextContent(customMessage); + }); + + it('does not enforce custom shipping rules when pickup is selected', async () => { + const customMessage = 'Shipping field is required'; + const draftOrder = makeFreePickupOrder({ + billing: { + firstName: 'Pat', + lastName: 'Pickup', + address: buildShippingAddress({ addressLine1: '' }), + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + shippingAddressLine2: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + enableShipping: true, + enableLocalPickup: true, + enableTaxCollection: false, + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + expect( + document.querySelector('input[name="shippingAddressLine2"]') + ).not.toBeInTheDocument(); + + await user.click(await clickSubmitButton(/complete your free order/i)); + + await waitFor(() => { + expect(getOperations('ConfirmCheckoutSession')).toHaveLength(1); + }); + expect(document.body).not.toHaveTextContent(customMessage); + }); + + it('does not enforce custom billing address rules when shipping address is reused', async () => { + const customMessage = 'Billing address line 2 is required'; + const sharedAddress = buildShippingAddress({ addressLine2: '' }); + const draftOrder = buildDraftOrder({ + shipping: { + firstName: 'Jane', + lastName: 'Buyer', + email: 'jane@example.com', + phone: '+12015550123', + address: sharedAddress, + }, + billing: { + firstName: 'Jane', + lastName: 'Buyer', + email: 'jane@example.com', + phone: '+12015550123', + address: sharedAddress, + }, + }); + const { user } = renderCheckout({ + draftOrder, + checkoutProps: { + checkoutFormSchema: { + billingAddressLine2: z.string().min(1, customMessage), + }, + }, + sessionOverrides: { + draftOrder, + paymentMethods: stripeOnlyPaymentMethods(), + }, + }); + await waitForCheckoutReady(); + clearOperations(); + + expect( + document.querySelector('input[name="billingAddressLine2"]') + ).not.toBeInTheDocument(); + + await user.click(await clickSubmitButton(/pay now/i)); + + await waitFor(() => { + expect(getOperations('TokenizeJs.getNonce')).toHaveLength(1); + }); + expect(document.body).not.toHaveTextContent(customMessage); + }); + it('pins current paid pickup card behavior when the billing address line is empty', async () => { const draftOrder = makePaidPickupOrder(); const { user } = renderCheckout({ diff --git a/packages/react/src/components/checkout/address/address-form.tsx b/packages/react/src/components/checkout/address/address-form.tsx index 5a80bbc5..c1c518a4 100644 --- a/packages/react/src/components/checkout/address/address-form.tsx +++ b/packages/react/src/components/checkout/address/address-form.tsx @@ -213,9 +213,9 @@ export function AddressForm({ () => addressFieldSuffixes.map(suffix => `${sectionKey}${suffix}`), [sectionKey] ); - const allSectionFieldNames = React.useMemo( - () => [...nameFieldNames, ...allAddressFieldNames], - [allAddressFieldNames, nameFieldNames] + const addressSyncFieldNames = React.useMemo( + () => [...allAddressFieldNames, 'paymentUseShippingAddress'], + [allAddressFieldNames] ); const orderAddress = React.useMemo( @@ -294,7 +294,7 @@ export function AddressForm({ React.useMemo( () => ({ id: `${sectionKey}-address`, - fieldNames: allSectionFieldNames, + fieldNames: addressSyncFieldNames, debounceMs: 1000, enabled: ({ values, draftOrder: currentDraftOrder }) => Boolean( @@ -330,7 +330,7 @@ export function AddressForm({ ); }, }), - [allSectionFieldNames, isAutocompleteOpen, onlyNames, sectionKey] + [addressSyncFieldNames, isAutocompleteOpen, onlyNames, sectionKey] ) ); @@ -346,7 +346,7 @@ export function AddressForm({ }); useDraftOrderFieldDirtyMarker({ id: `${sectionKey}-address`, - fieldNames: allSectionFieldNames, + fieldNames: addressSyncFieldNames, disabled: onlyNames || isConfirmingCheckout, }); diff --git a/packages/react/src/components/checkout/address/utils/check-is-valid-phone.ts b/packages/react/src/components/checkout/address/utils/check-is-valid-phone.ts index e0ea8a57..d476e252 100644 --- a/packages/react/src/components/checkout/address/utils/check-is-valid-phone.ts +++ b/packages/react/src/components/checkout/address/utils/check-is-valid-phone.ts @@ -1,7 +1,8 @@ import { isPossiblePhoneNumber } from 'react-phone-number-input'; export function checkIsValidPhone(phoneNumber: string): boolean { - if (!phoneNumber) return false; + const trimmed = phoneNumber?.trim(); + if (!trimmed) return false; - return isPossiblePhoneNumber(phoneNumber); + return isPossiblePhoneNumber(trimmed); } diff --git a/packages/react/src/components/checkout/checkout.tsx b/packages/react/src/components/checkout/checkout.tsx index a66fb390..e74c9068 100644 --- a/packages/react/src/components/checkout/checkout.tsx +++ b/packages/react/src/components/checkout/checkout.tsx @@ -110,6 +110,12 @@ interface CheckoutContextValue { checkoutErrors?: string[] | undefined; setCheckoutErrors: (error?: string[] | undefined) => void; requiredFields?: { [key: string]: boolean }; + /** + * Field names supplied through the `checkoutFormSchema` prop. Consumer rules + * must always be validated, even when the built-in conditional validation + * would skip that field for the current delivery/payment combination. + */ + customSchemaFields?: string[]; } export const checkoutContext = React.createContext({ @@ -410,6 +416,10 @@ export function Checkout(props: CheckoutProps) { return getRequiredFieldsFromSchema(formSchema); }, [formSchema]); + const customSchemaFields = React.useMemo(() => { + return Object.keys(checkoutFormSchema ?? {}); + }, [checkoutFormSchema]); + if (!props.isLoading && !isLoadingJWT && !session) { return (
@@ -459,6 +469,7 @@ export function Checkout(props: CheckoutProps) { paypalConfig, ccavenueConfig, requiredFields, + customSchemaFields, isConfirmingCheckout, setIsConfirmingCheckout, checkoutErrors, diff --git a/packages/react/src/components/checkout/contact/contact-form.tsx b/packages/react/src/components/checkout/contact/contact-form.tsx index 954288a9..dddeb505 100644 --- a/packages/react/src/components/checkout/contact/contact-form.tsx +++ b/packages/react/src/components/checkout/contact/contact-form.tsx @@ -30,14 +30,15 @@ export function ContactForm() { id: 'contact-email', fieldNames: ['contactEmail'], debounceMs: 1000, - enabled: ({ values, draftOrder: currentDraftOrder }) => - Boolean( - currentDraftOrder && - values.contactEmail?.trim() && - (currentDraftOrder.shipping?.email !== - values.contactEmail.trim() || - currentDraftOrder.billing?.email !== values.contactEmail.trim()) - ), + enabled: ({ values, draftOrder: currentDraftOrder }) => { + const email = values.contactEmail?.trim(); + if (!currentDraftOrder || !email) return false; + + return ( + currentDraftOrder.shipping?.email !== email || + currentDraftOrder.billing?.email !== email + ); + }, buildPatch: ({ values, draftOrder: currentDraftOrder }) => { const email = values.contactEmail?.trim(); if (!email || !currentDraftOrder) return null; diff --git a/packages/react/src/components/checkout/contact/phone-input.tsx b/packages/react/src/components/checkout/contact/phone-input.tsx index 7f3c67f9..7fed6979 100644 --- a/packages/react/src/components/checkout/contact/phone-input.tsx +++ b/packages/react/src/components/checkout/contact/phone-input.tsx @@ -244,13 +244,17 @@ export function PhoneInput({ ? currentDraftOrder.shipping : currentDraftOrder.billing; - if ((orderSection?.phone || '') === (phone || '')) return false; - return phone ? checkIsValidPhone(phone) && phone.trim() !== '' : true; + if ((orderSection?.phone || '') === (phone.trim() || '')) { + return false; + } + // An empty value clears the phone on the order; anything else has to + // be dialable before it is worth sending. + return phone.trim() ? checkIsValidPhone(phone) : true; }, buildPatch: ({ values }) => { const phone = String( values[phoneFieldName as keyof typeof values] ?? '' - ); + ).trim(); return mapAddressFieldsToInput( { phone }, sectionKey as 'shipping' | 'billing', diff --git a/packages/react/src/components/checkout/form/checkout-form.tsx b/packages/react/src/components/checkout/form/checkout-form.tsx index 47eaa8ef..3eacb11f 100644 --- a/packages/react/src/components/checkout/form/checkout-form.tsx +++ b/packages/react/src/components/checkout/form/checkout-form.tsx @@ -402,7 +402,7 @@ export function CheckoutForm({ return ( - +
{ methodsRef.current = methods; + customSchemaFieldsRef.current = customSchemaFields; + sessionRef.current = session; }); const enhancedMethods = useMemo(() => { @@ -58,10 +67,108 @@ export function CustomFormProvider< const isShipping = deliveryMethod === DeliveryMethods.SHIP; const isFreeOrder = paymentMethod === PaymentMethodType.OFFLINE; const isFreePickup = isFreeOrder && isPickup; + const currentSession = sessionRef.current; + let billingContext: + | 'top-level' + | 'inline-payment-form' + | 'free-payment-form' = 'top-level'; + if (hasInlineBillingForm(paymentMethod)) { + billingContext = 'inline-payment-form'; + } else if (isFreeOrder) { + billingContext = 'free-payment-form'; + } + const billingMode = getBillingCollectionMode({ + context: billingContext, + deliveryMethod, + paymentMethod, + paymentUseShippingAddress, + enableBillingAddressCollection: + currentSession?.enableBillingAddressCollection, + enableTaxCollection: currentSession?.enableTaxCollection, + }); // Get all field names and filter based on conditions const allFieldNames = Object.keys(values); let fieldNames = [...allFieldNames] as Array>; + const shippingAddressFieldNames = new Set([ + 'shippingFirstName', + 'shippingLastName', + 'shippingAddressLine1', + 'shippingAddressLine2', + 'shippingAddressLine3', + 'shippingAdminArea4', + 'shippingAdminArea3', + 'shippingAdminArea2', + 'shippingAdminArea1', + 'shippingPostalCode', + 'shippingCountryCode', + ]); + const billingAddressFieldNames = new Set([ + 'billingAddressLine1', + 'billingAddressLine2', + 'billingAddressLine3', + 'billingAdminArea4', + 'billingAdminArea3', + 'billingAdminArea2', + 'billingAdminArea1', + 'billingPostalCode', + 'billingCountryCode', + ]); + const billingNameFieldNames = new Set([ + 'billingFirstName', + 'billingLastName', + ]); + const shippingSectionIsCollectable = Boolean( + isShipping && currentSession?.enableShipping + ); + const shippingAddressIsCollectable = Boolean( + shippingSectionIsCollectable && + currentSession?.enableShippingAddressCollection + ); + const billingNamesAreCollectable = billingMode !== 'none'; + const billingAddressIsCollectable = billingMode === 'address'; + const phoneIsCollectable = + currentSession?.enablePhoneCollection !== false; + const notesAreCollectable = + currentSession?.enableNotesCollection !== false; + + const isCollectable = (fieldName: string) => { + if (fieldName === 'shippingPhone') { + return shippingAddressIsCollectable && phoneIsCollectable; + } + if (fieldName === 'billingPhone') { + return billingNamesAreCollectable && phoneIsCollectable; + } + if (shippingAddressFieldNames.has(fieldName)) { + return shippingAddressIsCollectable; + } + if (fieldName === 'shippingMethod') { + return shippingSectionIsCollectable; + } + if (billingNameFieldNames.has(fieldName)) { + return billingNamesAreCollectable; + } + if (billingAddressFieldNames.has(fieldName)) { + return billingAddressIsCollectable; + } + if (fieldName.startsWith('shipping')) { + return shippingSectionIsCollectable; + } + if (fieldName.startsWith('billing')) { + return billingNamesAreCollectable; + } + if (fieldName === 'notes') { + return notesAreCollectable; + } + return true; + }; + fieldNames = fieldNames.filter(fieldName => isCollectable(fieldName)); + + const customFieldNames = new Set( + (customSchemaFieldsRef.current ?? []).filter(isCollectable) + ); + const isSkippable = (fieldName: string) => + !customFieldNames.has(fieldName); /* For free pickup orders, only validate billingFirstName and billingLastName */ if (isFreePickup) { @@ -69,7 +176,8 @@ export function CustomFormProvider< fieldName => !fieldName.startsWith('billing') || fieldName === 'billingFirstName' || - fieldName === 'billingLastName' + fieldName === 'billingLastName' || + !isSkippable(fieldName) ); } else if (paymentUseShippingAddress && isShipping) { /* If using shipping address for billing, filter out billing-related field validations. @@ -77,24 +185,20 @@ export function CustomFormProvider< * fulfillment orders, or sessions with enableShipping: false, still validate * billing fields — there's no shipping address to copy from in those cases. */ fieldNames = fieldNames.filter( - fieldName => !fieldName.startsWith('billing') + fieldName => + !fieldName.startsWith('billing') || !isSkippable(fieldName) ); } /* If the delivery method is not shipping (i.e. pickup), filter out shipping-related field validations */ if (!isShipping) { fieldNames = fieldNames.filter( - fieldName => !fieldName.startsWith('shipping') + fieldName => + !fieldName.startsWith('shipping') || !isSkippable(fieldName) ); } - // Trigger validation only on the filtered fields if any condition is true, - // otherwise trigger on all fields - if (paymentUseShippingAddress || isPickup || isFreeOrder) { - result = await methods.trigger(fieldNames, triggerOptions); - } else { - result = await methods.trigger(undefined, triggerOptions); - } + result = await methods.trigger(fieldNames, triggerOptions); } // Force update to ensure error messages show immediately diff --git a/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx b/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx index 4e60df70..7ba0e2b7 100644 --- a/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx +++ b/packages/react/src/components/checkout/order/draft-order-sync-provider.integration.test.tsx @@ -22,6 +22,7 @@ import { flushPromises, getOperations, mockGodaddyApi, + setApiError, setApiErrorOnce, waitForOperation, } from '../__tests__/checkout-test-env'; @@ -278,6 +279,34 @@ describe('DraftOrderSyncProvider integration', () => { }); }); + it('replaces a rejected registration patch with the corrected value instead of retrying it', async () => { + const { user } = renderSyncHarness(); + setApiError('updateDraftOrder', new Error('invalid value')); + + await user.clear(screen.getByLabelText('first name')); + await user.type(screen.getByLabelText('first name'), 'Bad'); + await user.click( + screen.getByRole('button', { name: 'mark-shipping-name' }) + ); + await advance(100); + await waitForOperation('UpdateCheckoutSessionDraftOrder'); + expect(getLastUpdateInput()).toMatchObject({ + shipping: { firstName: 'Bad' }, + }); + + await user.clear(screen.getByLabelText('first name')); + await user.type(screen.getByLabelText('first name'), 'Good'); + await user.click( + screen.getByRole('button', { name: 'mark-shipping-name' }) + ); + await advance(100); + await waitForOperation('UpdateCheckoutSessionDraftOrder', 2); + + expect(getLastUpdateInput()).toMatchObject({ + shipping: { firstName: 'Good' }, + }); + }); + it('flushDraftOrderSync clears debounce work and waits for the mutation to settle', async () => { const { user } = renderSyncHarness({ updateDraftOrderDelayMs: 500 }); diff --git a/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx b/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx index d10c9b40..4e7e5246 100644 --- a/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx +++ b/packages/react/src/components/checkout/order/draft-order-sync-provider.tsx @@ -1,6 +1,7 @@ import { useQueryClient } from '@tanstack/react-query'; import * as React from 'react'; import { type UseFormReturn, useFormContext } from 'react-hook-form'; +import type { z } from 'zod'; import { type CheckoutFormData, useCheckoutContext, @@ -139,8 +140,14 @@ export function mergeDraftOrderPatch( export function DraftOrderSyncProvider({ children, + schema, }: { children: React.ReactNode; + /** + * The same schema the form resolver uses. Registrations are skipped while + * their fields are invalid so rejected values never reach the draft order. + */ + schema?: z.ZodTypeAny; }) { const updateDraftOrder = useUpdateOrder(); const queryClient = useQueryClient(); @@ -288,9 +295,36 @@ export function DraftOrderSyncProvider({ } }, [form, session, updateDraftOrder]); + /** + * Field names the form schema currently rejects. RHF's own error state cannot + * be used here: the form validates `onBlur`, so a debounced background sync + * runs before `formState.errors` knows about the value being typed, and + * `formState.isValid` is form-wide (false for any incomplete checkout). + * Re-running the resolver schema keeps validation in one place, including + * rules supplied through the `checkoutFormSchema` prop. + */ + const getInvalidFieldNames = React.useCallback( + (values: CheckoutFormData) => { + const invalidFieldNames = new Set(); + if (!schema) return invalidFieldNames; + + const result = schema.safeParse(values); + if (result.success) return invalidFieldNames; + + for (const issue of result.error.issues) { + const [fieldName] = issue.path; + if (typeof fieldName === 'string') invalidFieldNames.add(fieldName); + } + + return invalidFieldNames; + }, + [schema] + ); + const buildPatchFromRegistrations = React.useCallback( (ids: string[], draftOrder?: DraftOrder | null) => { const values = form.getValues(); + const invalidFieldNames = getInvalidFieldNames(values); const context: DraftOrderSyncRegistrationContext = { values, form, @@ -304,6 +338,18 @@ export function DraftOrderSyncProvider({ for (const id of ids) { const registration = registrationsRef.current.get(id); if (!registration) continue; + // Only the values the customer edited have to be valid. Untouched + // fields can be invalid simply because the order is still incomplete + // (for example missing names while the address is being filled in). + if ( + registration.fieldNames.some( + fieldName => + invalidFieldNames.has(fieldName) && + form.getFieldState(fieldName as keyof CheckoutFormData).isDirty + ) + ) { + continue; + } if (registration.enabled?.(context) === false) continue; const registrationPatch = registration.buildPatch(context); @@ -325,7 +371,7 @@ export function DraftOrderSyncProvider({ registrationIds: [...registrationIds], }; }, - [form, session] + [form, getInvalidFieldNames, session] ); const flushDraftOrderSync = React.useCallback( @@ -334,12 +380,39 @@ export function DraftOrderSyncProvider({ ): Promise => { clearTimer(); + const canBuildRegistrationPatches = + !isConfirmingCheckout || Boolean(options.allowWhileConfirming); + + // A patch the backend rejected stays queued, so rebuild the dirty + // registrations from the current form values and merge them over it + // before draining. Without this the queue keeps retrying the rejected + // value and a corrected value never replaces it. + if (canBuildRegistrationPatches && pendingPatchRef.current) { + const queuedIds = [...dirtyRegistrationIdsRef.current]; + + if (queuedIds.length) { + const rebuilt = buildPatchFromRegistrations( + queuedIds, + getCurrentDraftOrder() + ); + + if (rebuilt.patch) { + for (const registrationId of rebuilt.registrationIds) { + dirtyRegistrationIdsRef.current.delete(registrationId); + } + queuePatch( + rebuilt.patch, + rebuilt.fieldNames, + rebuilt.registrationIds + ); + } + } + } + let patchSent = await drainQueue(); let latestBeforePatch = options.includeCurrentValues ? await refetchLatestDraftOrder() : getCurrentDraftOrder(); - const canBuildRegistrationPatches = - !isConfirmingCheckout || Boolean(options.allowWhileConfirming); let ids: string[] = []; if (canBuildRegistrationPatches) {