Skip to content

Commit

Permalink
Error handling across Hydrogen (#1645)
Browse files Browse the repository at this point in the history
Co-authored-by: Fran Dios <fran.dios@shopify.com>
  • Loading branch information
wizardlyhel and frandiox authored Jan 19, 2024
1 parent 335375a commit 400bfee
Show file tree
Hide file tree
Showing 46 changed files with 326 additions and 188 deletions.
22 changes: 22 additions & 0 deletions .changeset/happy-pigs-collect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
'@shopify/hydrogen': patch
'@shopify/cli-hydrogen': patch
---

Better Hydrogen error handling

* Fix storefront client throwing on partial successful errors
* Fix subrequest profiler to better display network errors with url information for SFAPI requests

### Breaking change

Mutation methods of `createCartHandler` used to return an `errors` object that contains `userErrors`. This is now changed back to `userErrors` to be consistent with SFAPI schema.

The `errors` object will now be used for Graphql execution errors.

`storefront.isApiError` is deprecated.

Updated types:

* `cart.get()` used to return a `Cart` type. Now it returns `CartReturn` type to accommodate the `errors` object
* All other `cart` methods (ie. `cart.addLines`) used to return a `CartQueryData` type. Now it returns `CartQueryDataReturn` type to accommodate the `errors` object.
4 changes: 2 additions & 2 deletions examples/multipass/app/routes/cart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Await, type MetaFunction} from '@remix-run/react';
import {Suspense} from 'react';
import type {CartQueryData} from '@shopify/hydrogen';
import type {CartQueryDataReturn} from '@shopify/hydrogen';
import {CartForm} from '@shopify/hydrogen';
import {json, type ActionFunctionArgs} from '@shopify/remix-oxygen';
import {CartMain} from '~/components/Cart';
Expand All @@ -25,7 +25,7 @@ export async function action({request, context}: ActionFunctionArgs) {
}

let status = 200;
let result: CartQueryData;
let result: CartQueryDataReturn;

switch (action) {
case CartForm.ACTIONS.LinesAdd:
Expand Down
2 changes: 1 addition & 1 deletion examples/multipass/app/routes/search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export async function loader({request, context}: LoaderFunctionArgs) {
};
}

const data = await context.storefront.query(SEARCH_QUERY, {
const {errors, ...data} = await context.storefront.query(SEARCH_QUERY, {
variables: {
query: searchTerm,
...variables,
Expand Down
4 changes: 2 additions & 2 deletions examples/subscriptions/app/routes/cart.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {Await, type MetaFunction} from '@remix-run/react';
import {Suspense} from 'react';
import type {CartQueryData} from '@shopify/hydrogen';
import type {CartQueryDataReturn} from '@shopify/hydrogen';
import {CartForm} from '@shopify/hydrogen';
import {json, type ActionFunctionArgs} from '@shopify/remix-oxygen';
import {CartMain} from '~/components/Cart';
Expand All @@ -22,7 +22,7 @@ export async function action({request, context}: ActionFunctionArgs) {
}

let status = 200;
let result: CartQueryData;
let result: CartQueryDataReturn;

switch (action) {
case CartForm.ACTIONS.LinesAdd:
Expand Down
26 changes: 18 additions & 8 deletions packages/cli/src/lib/request-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type H2OEvent = {
key?: string | readonly unknown[];
};
displayName?: string;
url?: string;
};

async function getRequestInfo(request: RequestKind) {
Expand Down Expand Up @@ -104,19 +105,27 @@ export function createLogRequestEvent(options?: {absoluteBundlePath?: string}) {
return createResponse<R>();
}

const {eventType, purpose, graphql, stackInfo, ...data} =
await getRequestInfo(request);
const {
url: displayUrl,
displayName: displayNameData,
eventType,
purpose,
graphql,
stackInfo,
...data
} = await getRequestInfo(request);

let graphiqlLink = '';
let description = request.url;
let descriptionUrl = request.url;
let displayName = displayNameData;

if (eventType === 'subrequest') {
description =
displayName =
displayName ||
graphql?.query
.match(/(query|mutation)\s+(\w+)/)?.[0]
?.replace(/\s+/, ' ') ||
decodeURIComponent(url.search.slice(1)) ||
request.url;
?.replace(/\s+/, ' ');
descriptionUrl = displayUrl || request.url;

if (graphql) {
graphiqlLink = getGraphiQLUrl({graphql});
Expand Down Expand Up @@ -150,7 +159,8 @@ export function createLogRequestEvent(options?: {absoluteBundlePath?: string}) {
event: EVENT_MAP[eventType] || eventType,
data: JSON.stringify({
...data,
url: `${purpose} ${description}`.trim(),
displayName,
url: `${purpose} ${descriptionUrl}`.trim(),
graphiqlLink,
stackLine,
stackLink,
Expand Down
9 changes: 3 additions & 6 deletions packages/hydrogen/src/cache/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
export type CacheKey = string | readonly unknown[];

export type FetchDebugInfo = {
url?: string;
requestId?: string | null;
graphql?: string | null;
purpose?: string | null;
Expand Down Expand Up @@ -134,7 +135,6 @@ export async function runWithCache<T = unknown>(
}) => {
globalThis.__H2O_LOG_EVENT?.({
eventType: 'subrequest',
url: debugData?.url || getKeyUrl(key),
startTime: overrideStartTime || startTime,
cacheStatus,
responsePayload: (result && result[0]) || result,
Expand All @@ -146,6 +146,7 @@ export async function runWithCache<T = unknown>(
},
waitUntil,
...debugInfo,
url: debugData?.url || debugInfo?.url,
displayName: debugInfo?.displayName || debugData?.displayName,
} as any);
}
Expand Down Expand Up @@ -271,11 +272,7 @@ export async function fetchWithServerCache(
data = await response.text();
} catch {
// Getting a response without a valid body
throw new Error(
`Storefront API response code: ${
response.status
} (Request Id: ${response.headers.get('x-request-id')})`,
);
return toSerializableResponse('', response);
}
}

Expand Down
4 changes: 2 additions & 2 deletions packages/hydrogen/src/cart/CartForm.custom.example.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {type ActionFunctionArgs, json} from '@remix-run/server-runtime';
import {
type CartQueryData,
type CartQueryDataReturn,
type HydrogenCart,
CartForm,
} from '@shopify/hydrogen';
Expand Down Expand Up @@ -36,7 +36,7 @@ export async function action({request, context}: ActionFunctionArgs) {
const {action, inputs} = CartForm.getFormInput(formData);

let status = 200;
let result: CartQueryData;
let result: CartQueryDataReturn;

if (action === 'CustomEditInPlace') {
result = await cart.addLines(inputs.addLines as CartLineInput[]);
Expand Down
4 changes: 2 additions & 2 deletions packages/hydrogen/src/cart/CartForm.example.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {type ActionFunctionArgs, json} from '@remix-run/server-runtime';
import {
type CartQueryData,
type CartQueryDataReturn,
type HydrogenCart,
CartForm,
} from '@shopify/hydrogen';
Expand Down Expand Up @@ -35,7 +35,7 @@ export async function action({request, context}: ActionFunctionArgs) {
const {action, inputs} = CartForm.getFormInput(formData);

let status = 200;
let result: CartQueryData;
let result: CartQueryDataReturn;

if (action === CartForm.ACTIONS.LinesUpdate) {
result = await cart.updateLines(inputs.lines);
Expand Down
4 changes: 2 additions & 2 deletions packages/hydrogen/src/cart/CartForm.fetcher.example.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {useFetcher} from '@remix-run/react';
import {type ActionFunctionArgs, json} from '@remix-run/server-runtime';
import {
type CartQueryData,
type CartQueryDataReturn,
type HydrogenCart,
CartForm,
type CartActionInput,
Expand Down Expand Up @@ -57,7 +57,7 @@ export async function action({request, context}: ActionFunctionArgs) {
const {action, inputs} = CartForm.getFormInput(formData);

let status = 200;
let result: CartQueryData;
let result: CartQueryDataReturn;

if (action === CartForm.ACTIONS.MetafieldsSet) {
result = await cart.setMetafields(inputs.metafields);
Expand Down
4 changes: 2 additions & 2 deletions packages/hydrogen/src/cart/CartForm.input-tag.example.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {type ActionFunctionArgs, json} from '@remix-run/server-runtime';
import {
type CartQueryData,
type CartQueryDataReturn,
type HydrogenCart,
CartForm,
} from '@shopify/hydrogen';
Expand All @@ -25,7 +25,7 @@ export async function action({request, context}: ActionFunctionArgs) {
const {action, inputs} = CartForm.getFormInput(formData);

let status = 200;
let result: CartQueryData;
let result: CartQueryDataReturn;

if (action === CartForm.ACTIONS.NoteUpdate) {
result = await cart.updateNote(inputs.note);
Expand Down
25 changes: 18 additions & 7 deletions packages/hydrogen/src/cart/cart-test-helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,25 @@ function storefrontMutate(
cartId = 'c1-new-cart-id';
}

return Promise.resolve({
[keyWrapper]: {
cart: {
id: cartId,
if (
keyWrapper === 'cartMetafieldsSet' ||
keyWrapper === 'cartMetafieldDelete'
) {
return Promise.resolve({
[keyWrapper]: {
userErrors: [query],
},
errors: [query],
},
});
});
} else {
return Promise.resolve({
[keyWrapper]: {
cart: {
id: cartId,
},
userErrors: [query],
},
});
}
}

export function mockHeaders(cartId?: string) {
Expand Down
22 changes: 11 additions & 11 deletions packages/hydrogen/src/cart/createCartHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,42 +98,42 @@ describe('createCartHandler', () => {
});

const result1 = await cart.create({});
expect(result1.errors?.[0]).toContain(cartMutateFragment);
expect(result1.userErrors?.[0]).toContain(cartMutateFragment);

const result2 = await cart.addLines([]);
expect(result2.errors?.[0]).toContain(cartMutateFragment);
expect(result2.userErrors?.[0]).toContain(cartMutateFragment);

const result3 = await cart.updateLines([]);
expect(result3.errors?.[0]).toContain(cartMutateFragment);
expect(result3.userErrors?.[0]).toContain(cartMutateFragment);

const result4 = await cart.removeLines([]);
expect(result4.errors?.[0]).toContain(cartMutateFragment);
expect(result4.userErrors?.[0]).toContain(cartMutateFragment);

const result5 = await cart.updateDiscountCodes([]);
expect(result5.errors?.[0]).toContain(cartMutateFragment);
expect(result5.userErrors?.[0]).toContain(cartMutateFragment);

const result6 = await cart.updateBuyerIdentity({});
expect(result6.errors?.[0]).toContain(cartMutateFragment);
expect(result6.userErrors?.[0]).toContain(cartMutateFragment);

const result7 = await cart.updateNote('');
expect(result7.errors?.[0]).toContain(cartMutateFragment);
expect(result7.userErrors?.[0]).toContain(cartMutateFragment);

const result8 = await cart.updateSelectedDeliveryOption([
{
deliveryGroupId: 'gid://shopify/DeliveryGroup/123',
deliveryOptionHandle: 'Postal Service',
},
]);
expect(result8.errors?.[0]).toContain(cartMutateFragment);
expect(result8.userErrors?.[0]).toContain(cartMutateFragment);

const result9 = await cart.updateAttributes([]);
expect(result9.errors?.[0]).toContain(cartMutateFragment);
expect(result9.userErrors?.[0]).toContain(cartMutateFragment);

const result10 = await cart.setMetafields([]);
expect(result10.errors?.[0]).not.toContain(cartMutateFragment);
expect(result10.userErrors?.[0]).not.toContain(cartMutateFragment);

const result11 = await cart.deleteMetafield('some.key');
expect(result11.errors?.[0]).not.toContain(cartMutateFragment);
expect(result11.userErrors?.[0]).not.toContain(cartMutateFragment);
});

it('function get has a working default implementation', async () => {
Expand Down
12 changes: 10 additions & 2 deletions packages/hydrogen/src/cart/queries/cart-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type {
MetafieldsSetUserError,
MetafieldDeleteUserError,
} from '@shopify/hydrogen-react/storefront-api-types';
import {type Storefront} from '../../storefront';
import type {StorefrontApiErrors, Storefront} from '../../storefront';

export type CartOptionalInput = {
/**
Expand Down Expand Up @@ -45,14 +45,22 @@ export type CartQueryOptions = {
cartFragment?: string;
};

export type CartReturn = Cart & {
errors?: StorefrontApiErrors;
};

export type CartQueryData = {
cart: Cart;
errors?:
userErrors?:
| CartUserError[]
| MetafieldsSetUserError[]
| MetafieldDeleteUserError[];
};

export type CartQueryDataReturn = CartQueryData & {
errors?: StorefrontApiErrors;
};

export type CartQueryReturn<T> = (
requiredParams: T,
optionalParams?: CartOptionalInput,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,6 @@ describe('cartAttributesUpdateDefault', () => {
const result = await cartAttribute([]);

expect(result.cart).toHaveProperty('id', CART_ID);
expect(result.errors?.[0]).toContain(cartFragment);
expect(result.userErrors?.[0]).toContain(cartFragment);
});
});
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
import {StorefrontApiErrors, formatAPIResult} from '../../storefront';
import {MINIMAL_CART_FRAGMENT, USER_ERROR_FRAGMENT} from './cart-fragments';
import type {
CartOptionalInput,
CartQueryData,
CartQueryDataReturn,
CartQueryOptions,
} from './cart-types';
import type {AttributeInput} from '@shopify/hydrogen-react/storefront-api-types';

export type CartAttributesUpdateFunction = (
attributes: AttributeInput[],
optionalParams?: CartOptionalInput,
) => Promise<CartQueryData>;
) => Promise<CartQueryDataReturn>;

export function cartAttributesUpdateDefault(
options: CartQueryOptions,
): CartAttributesUpdateFunction {
return async (attributes, optionalParams) => {
const {cartAttributesUpdate} = await options.storefront.mutate<{
const {cartAttributesUpdate, errors} = await options.storefront.mutate<{
cartAttributesUpdate: CartQueryData;
errors: StorefrontApiErrors;
}>(CART_ATTRIBUTES_UPDATE_MUTATION(options.cartFragment), {
variables: {
cartId: optionalParams?.cartId || options.getCartId(),
attributes,
},
});
return cartAttributesUpdate;
return formatAPIResult(cartAttributesUpdate, errors);
};
}

Expand All @@ -38,7 +41,7 @@ export const CART_ATTRIBUTES_UPDATE_MUTATION = (
cart {
...CartApiMutation
}
errors: userErrors {
userErrors {
...CartApiError
}
}
Expand Down
Loading

0 comments on commit 400bfee

Please sign in to comment.