Skip to content

Commit

Permalink
Change useOptimisticProduct to useOptimisticVariant (#2291)
Browse files Browse the repository at this point in the history
  • Loading branch information
blittle authored Jul 5, 2024
1 parent a5e03e2 commit 6379d32
Show file tree
Hide file tree
Showing 8 changed files with 93 additions and 91 deletions.
15 changes: 9 additions & 6 deletions .changeset/wet-yaks-think.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@
'@shopify/hydrogen': patch
---

Add a `useOptimisticProduct` hook for optimistically rendering product variant changes. This makes switching product variants instantaneous. Example usage:
Add a `useOptimisticVariant` hook for optimistically rendering product variant changes. This makes switching product variants instantaneous. Example usage:

```tsx
function Product() {
const {product: originalProduct, variants} = useLoaderData<typeof loader>();
const {product, variants} = useLoaderData<typeof loader>();

// The product.selectedVariant optimistically changed during a page
// transition with one of the preloaded product variants
const product = useOptimisticProduct(originalProduct, variants);
// The selectedVariant optimistically changes during page
// transitions with one of the preloaded product variants
const selectedVariant = useOptimisticVariant(
product.selectedVariant,
variants,
);

return <ProductMain product={product} />;
return <ProductMain selectedVariant={selectedVariant} />;
}
```

Expand Down
2 changes: 1 addition & 1 deletion packages/hydrogen/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ export {
getSelectedProductOptions,
} from './product/VariantSelector';

export {useOptimisticProduct} from './product/useOptimisticProduct';
export {useOptimisticVariant} from './product/useOptimisticVariant';

export type {
VariantOption,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs';

const data: ReferenceEntityTemplateSchema = {
name: 'useOptimisticProduct',
name: 'useOptimisticVariant',
category: 'hooks',
isVisualComponent: false,
related: [
Expand All @@ -16,20 +16,20 @@ const data: ReferenceEntityTemplateSchema = {
url: '/docs/api/hydrogen/2024-04/hooks/useoptimisticcart',
},
],
description: `The \`useOptimisticProduct\` takes an existing product object, processes a pending navigation to a product variant, and locally mutates the product with optimistic state. This makes switching product options immediate. It requires that the product query include a \`selectedVariant\` field populated by \`variantBySelectedOptions\`.`,
description: `The \`useOptimisticVariant\` takes an existing product variant, processes a pending navigation to another product variant, and returns the data of the destination variant. This makes switching product options immediate.`,
type: 'component',
defaultExample: {
description: 'I am the default example',
codeblock: {
tabs: [
{
title: 'JavaScript',
code: './useOptimisticProduct.example.jsx',
code: './useOptimisticVariant.example.jsx',
language: 'jsx',
},
{
title: 'TypeScript',
code: './useOptimisticProduct.example.tsx',
code: './useOptimisticVariant.example.tsx',
language: 'tsx',
},
],
Expand All @@ -39,7 +39,7 @@ const data: ReferenceEntityTemplateSchema = {
definitions: [
{
title: 'Props',
type: 'UseOptimisticProductGeneratedType',
type: 'useOptimisticVariantGeneratedType',
description: '',
},
],
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import {useLoaderData} from '@remix-run/react';
import {defer} from '@remix-run/server-runtime';
import {useOptimisticProduct} from '@shopify/hydrogen';
import {useOptimisticVariant} from '@shopify/hydrogen';

export async function loader({context}) {
return defer({
product: await context.storefront.query('/** product query **/'),
// Note that variants does not need to be awaited to be used by `useOptimisticProduct`
// Note that variants does not need to be awaited to be used by `useOptimisticVariant`
variants: context.storefront.query('/** variants query **/'),
});
}

function Product() {
const {product: originalProduct, variants} = useLoaderData();
const {product, variants} = useLoaderData();

// The product.selectedVariant optimistically changed during a page
// transition with one of the preloaded product variants
const product = useOptimisticProduct(originalProduct, variants);
// The selectedVariant optimistically changes during page
// transitions with one of the preloaded product variants
const selectedVariant = useOptimisticVariant(
product.selectedVariant,
variants,
);

// @ts-ignore
return <ProductMain product={product} />;
return <ProductMain selectedVariant={selectedVariant} />;
}
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import {useLoaderData} from '@remix-run/react';
import {defer, LoaderFunctionArgs} from '@remix-run/server-runtime';
import {useOptimisticProduct} from '@shopify/hydrogen';
import {useOptimisticVariant} from '@shopify/hydrogen';

export async function loader({context}: LoaderFunctionArgs) {
return defer({
product: await context.storefront.query('/** product query */'),
// Note that variants does not need to be awaited to be used by `useOptimisticProduct`
// Note that variants does not need to be awaited to be used by `useOptimisticVariant`
variants: context.storefront.query('/** variants query */'),
});
}

function Product() {
const {product: originalProduct, variants} = useLoaderData<typeof loader>();
const {product, variants} = useLoaderData<typeof loader>();

// The product.selectedVariant optimistically changed during a page
// transition with one of the preloaded product variants
const product = useOptimisticProduct(originalProduct, variants);
// The selectedVariant optimistically changes during page
// transitions with one of the preloaded product variants
const selectedVariant = useOptimisticVariant(
product.selectedVariant,
variants,
);

// @ts-ignore
return <ProductMain product={product} />;
return <ProductMain selectedVariant={selectedVariant} />;
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import {expect, test, describe, beforeEach, afterEach, vi} from 'vitest';
import {useOptimisticProduct} from './useOptimisticProduct';
import {useOptimisticVariant} from './useOptimisticVariant';
import {renderHook, waitFor} from '@testing-library/react';

let navigation = {state: 'idle', location: {search: ''}};

describe('useOptimisticProduct', () => {
describe('useOptimisticVariant', () => {
beforeEach(() => {
vi.mock('@remix-run/react', async (importOrigninal) => {
return {
Expand All @@ -21,31 +21,31 @@ describe('useOptimisticProduct', () => {
vi.clearAllMocks();
});
test('returns the original product if no fetchers are present', () => {
const product = {title: 'Product'};
const variant = {title: 'Product'};
const {result} = renderHook(() =>
useOptimisticProduct(product, {
useOptimisticVariant(variant, {
product: {
variants: {nodes: []},
},
}),
);
expect(result.current).toEqual(product);
expect(result.current).toEqual(variant);
});

test('returns the original product if no variants provided', () => {
navigation = {
state: 'loading',
location: {search: new URLSearchParams('?variant=123').toString()},
};
const product = {title: 'Product'};
const variant = {title: 'Product'};
const {result} = renderHook(() =>
useOptimisticProduct(product, {
useOptimisticVariant(variant, {
product: {
variants: {nodes: []},
},
}),
);
expect(result.current).toEqual(product);
expect(result.current).toEqual(variant);
});

test('returns an optimistic product', async () => {
Expand All @@ -57,9 +57,9 @@ describe('useOptimisticProduct', () => {
).toString(),
},
};
const product = {title: 'Product'};
const variant = {title: 'Product'};
const {result} = renderHook(() =>
useOptimisticProduct(product, {
useOptimisticVariant(variant, {
product: {
variants: {
nodes: [
Expand All @@ -78,9 +78,8 @@ describe('useOptimisticProduct', () => {
);

await waitFor(() => {
expect(result.current.isOptimistic).toEqual(true);
// @ts-expect-error
expect(result.current.selectedVariant).toEqual({
expect(result.current).toEqual({
isOptimistic: true,
id: 'gid://shopify/ProductVariant/123',
title: '158cm Sea Green / Desert',
selectedOptions: [
Expand All @@ -100,9 +99,9 @@ describe('useOptimisticProduct', () => {
).toString(),
},
};
const product = {title: 'Product'};
const variant = {title: 'Product'};
const {result} = renderHook(() =>
useOptimisticProduct(product, [
useOptimisticVariant(variant, [
{
id: 'gid://shopify/ProductVariant/123',
title: '158cm Sea Green / Desert',
Expand All @@ -115,9 +114,8 @@ describe('useOptimisticProduct', () => {
);

await waitFor(() => {
expect(result.current.isOptimistic).toEqual(true);
// @ts-expect-error
expect(result.current.selectedVariant).toEqual({
expect(result.current).toEqual({
isOptimistic: true,
id: 'gid://shopify/ProductVariant/123',
title: '158cm Sea Green / Desert',
selectedOptions: [
Expand All @@ -137,9 +135,9 @@ describe('useOptimisticProduct', () => {
).toString(),
},
};
const product = {title: 'Product'};
const variant = {title: 'Product'};
const {result} = renderHook(() =>
useOptimisticProduct(product, {
useOptimisticVariant(variant, {
product: {
variants: {
nodes: [
Expand All @@ -158,9 +156,8 @@ describe('useOptimisticProduct', () => {
);

await waitFor(() => {
expect(result.current.isOptimistic).toEqual(true);
// @ts-expect-error
expect(result.current.selectedVariant).toEqual({
expect(result.current).toEqual({
isOptimistic: true,
id: 'gid://shopify/ProductVariant/123',
title: '158cm Sea Green / Desert',
selectedOptions: [
Expand All @@ -180,9 +177,9 @@ describe('useOptimisticProduct', () => {
).toString(),
},
};
const product = {title: 'Product'};
const variant = {title: 'Product'};
renderHook(() =>
useOptimisticProduct(product, {
useOptimisticVariant(variant, {
product: {
variants: {
nodes: [
Expand All @@ -199,7 +196,7 @@ describe('useOptimisticProduct', () => {
await waitFor(() => {
expect(globalThis.reportError).toHaveBeenCalledWith(
new Error(
'[h2:error:useOptimisticProduct] The optimistic product hook requires your product query to include variants with the selectedOptions field.',
'[h2:error:useOptimisticVariant] The optimistic product hook requires your product query to include variants with the selectedOptions field.',
),
);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
import {useNavigation} from '@remix-run/react';
import {
Product,
ProductVariant,
} from '@shopify/hydrogen-react/storefront-api-types';
import {ProductVariant} from '@shopify/hydrogen-react/storefront-api-types';
import {useEffect, useState} from 'react';
import type {PartialDeep} from 'type-fest';

type OptimisticProduct<T> = T & {
type OptimisticVariant<T> = T & {
isOptimistic?: boolean;
};

type OptimisticProductInput = Product & {
selectedVariant?: PartialDeep<ProductVariant>;
};
type OptimisticVariantInput = PartialDeep<ProductVariant>;

type OptimisticProductVariants =
| Array<PartialDeep<ProductVariant>>
Expand All @@ -21,17 +16,17 @@ type OptimisticProductVariants =
| Promise<PartialDeep<ProductVariant>>;

/**
* @param product The product object from `context.storefront.query()` returned by a server loader. The query should use the `selectedVariant` field with `variantBySelectedOptions`.
* @param selectedVariant The `selectedVariant` field queried with `variantBySelectedOptions`.
* @param variants The available product variants for the product. This can be an array of variants, a promise that resolves to an array of variants, or an object with a `product` key that contains the variants.
* @returns A new product object where the `selectedVariant` property is set to the variant that matches the current URL search params. If no variant is found, the original product object is returned. The `isOptimistic` property is set to `true` if the `selectedVariant` has been optimistically changed.
*/
export function useOptimisticProduct<
ProductWithSelectedVariant = OptimisticProductInput,
export function useOptimisticVariant<
SelectedVariant = OptimisticVariantInput,
Variants = OptimisticProductVariants,
>(
product: ProductWithSelectedVariant,
selectedVariant: SelectedVariant,
variants: Variants,
): OptimisticProduct<ProductWithSelectedVariant> {
): OptimisticVariant<SelectedVariant> {
const navigation = useNavigation();
const [resolvedVariants, setResolvedVariants] = useState<
Array<PartialDeep<ProductVariant>>
Expand All @@ -52,7 +47,7 @@ export function useOptimisticProduct<
.catch((error) => {
reportError(
new Error(
'[h2:error:useOptimisticProduct] An error occurred while resolving the variants for the optimistic product hook.',
'[h2:error:useOptimisticVariant] An error occurred while resolving the variants for the optimistic product hook.',
{
cause: error,
},
Expand All @@ -66,33 +61,31 @@ export function useOptimisticProduct<
let reportedError = false;

// Find matching variant
const selectedVariant =
resolvedVariants.find((variant) => {
if (!variant.selectedOptions) {
if (!reportedError) {
reportedError = true;
reportError(
new Error(
'[h2:error:useOptimisticProduct] The optimistic product hook requires your product query to include variants with the selectedOptions field.',
),
);
}
return false;
const matchingVariant = resolvedVariants.find((variant) => {
if (!variant.selectedOptions) {
if (!reportedError) {
reportedError = true;
reportError(
new Error(
'[h2:error:useOptimisticVariant] The optimistic product hook requires your product query to include variants with the selectedOptions field.',
),
);
}
return false;
}

return variant.selectedOptions.every((option) => {
return queryParams.get(option.name) === option.value;
});
}) || (product as OptimisticProductInput).selectedVariant;
return variant.selectedOptions.every((option) => {
return queryParams.get(option.name) === option.value;
});
});

if (selectedVariant) {
if (matchingVariant) {
return {
...product,
...matchingVariant,
isOptimistic: true,
selectedVariant,
};
} as OptimisticVariant<SelectedVariant>;
}
}

return product as OptimisticProduct<ProductWithSelectedVariant>;
return selectedVariant as OptimisticVariant<SelectedVariant>;
}
Loading

0 comments on commit 6379d32

Please sign in to comment.