Skip to content

Commit

Permalink
Add an optimistic product hook (#2183)
Browse files Browse the repository at this point in the history
* Add a `useOptimisticProduct` hook
  • Loading branch information
blittle authored Jun 21, 2024
1 parent 6a6278b commit e432533
Show file tree
Hide file tree
Showing 10 changed files with 457 additions and 7 deletions.
29 changes: 29 additions & 0 deletions .changeset/wet-yaks-think.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
'@shopify/hydrogen': patch
---

Add a `useOptimisticProduct` 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>();

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

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

This also introduces a small breaking change to the `VariantSelector` component, which now immediately updates which variant is active. If you'd like to retain the current functionality, and have the `VariantSelector` wait for the page navigation to complete before updating, use the `waitForNavigation` prop:

```tsx
<VariantSelector
handle={product.handle}
options={product.options}
waitForNavigation
>
...
</VariantSelector>
```
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {useOptimisticCart} from './useOptimisticCart';
import * as RemixReact from '@remix-run/react';
import {type CartActionInput, CartForm} from '../CartForm';
import {FormData} from 'formdata-polyfill/esm.min.js';
import {CartReturn} from '../queries/cart-types';

let fetchers: {formData: FormData}[] = [];

Expand Down
2 changes: 2 additions & 0 deletions packages/hydrogen/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ export {
getSelectedProductOptions,
} from './product/VariantSelector';

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

export type {
VariantOption,
VariantOptionValue,
Expand Down
25 changes: 21 additions & 4 deletions packages/hydrogen/src/product/VariantSelector.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {useLocation} from '@remix-run/react';
import {useLocation, useNavigation} from '@remix-run/react';
import {flattenConnection} from '@shopify/hydrogen-react';
import type {
ProductOption,
Expand Down Expand Up @@ -35,6 +35,8 @@ type VariantSelectorProps = {
| Array<PartialDeep<ProductVariant>>;
/** By default all products are under /products. Use this prop to provide a custom path. */
productPath?: string;
/** Should the VariantSelector wait to update until after the browser navigates to a variant. */
waitForNavigation?: boolean;
children: ({option}: {option: VariantOption}) => ReactNode;
};

Expand All @@ -43,6 +45,7 @@ export function VariantSelector({
options = [],
variants: _variants = [],
productPath = 'products',
waitForNavigation = false,
children,
}: VariantSelectorProps) {
const variants =
Expand All @@ -51,6 +54,7 @@ export function VariantSelector({
const {searchParams, path, alreadyOnProductPage} = useVariantPath(
handle,
productPath,
waitForNavigation,
);

// If an option only has one value, it doesn't need a UI to select it
Expand Down Expand Up @@ -167,8 +171,13 @@ export const getSelectedProductOptions: GetSelectedProductOptions = (
return selectedOptions;
};

function useVariantPath(handle: string, productPath: string) {
function useVariantPath(
handle: string,
productPath: string,
waitForNavigation: boolean,
) {
const {pathname, search} = useLocation();
const navigation = useNavigation();

return useMemo(() => {
const match = /(\/[a-zA-Z]{2}-[a-zA-Z]{2}\/)/g.exec(pathname);
Expand All @@ -181,7 +190,15 @@ function useVariantPath(handle: string, productPath: string) {
? `${match![0]}${productPath}/${handle}`
: `/${productPath}/${handle}`;

const searchParams = new URLSearchParams(search);
const searchParams = new URLSearchParams(
// Remix doesn't update the location until pending loaders complete.
// By default we use the destination search params to make selecting a variant
// instant, but `waitForNavigation` makes the UI wait to update by only using
// the active browser search params.
waitForNavigation || navigation.state !== 'loading'
? search
: navigation.location.search,
);

return {
searchParams,
Expand All @@ -191,5 +208,5 @@ function useVariantPath(handle: string, productPath: string) {
alreadyOnProductPage: path === pathname,
path,
};
}, [pathname, search, handle, productPath]);
}, [pathname, search, waitForNavigation, handle, productPath, navigation]);
}
48 changes: 48 additions & 0 deletions packages/hydrogen/src/product/useOptimisticProduct.doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs';

const data: ReferenceEntityTemplateSchema = {
name: 'useOptimisticProduct',
category: 'hooks',
isVisualComponent: false,
related: [
{
name: 'VariantSelector',
type: 'components',
url: '/docs/api/hydrogen/2024-04/components/variantselector',
},
{
name: 'useOptimisticCart',
type: 'hooks',
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\`.`,
type: 'component',
defaultExample: {
description: 'I am the default example',
codeblock: {
tabs: [
{
title: 'JavaScript',
code: './useOptimisticProduct.example.jsx',
language: 'jsx',
},
{
title: 'TypeScript',
code: './useOptimisticProduct.example.tsx',
language: 'tsx',
},
],
title: 'Example code',
},
},
definitions: [
{
title: 'Props',
type: 'UseOptimisticProductGeneratedType',
description: '',
},
],
};

export default data;
22 changes: 22 additions & 0 deletions packages/hydrogen/src/product/useOptimisticProduct.example.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {useLoaderData} from '@remix-run/react';
import {defer} from '@remix-run/server-runtime';
import {useOptimisticProduct} 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`
variants: context.storefront.query('/** variants query **/'),
});
}

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

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

// @ts-ignore
return <ProductMain product={product} />;
}
22 changes: 22 additions & 0 deletions packages/hydrogen/src/product/useOptimisticProduct.example.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {useLoaderData} from '@remix-run/react';
import {defer, LoaderFunctionArgs} from '@remix-run/server-runtime';
import {useOptimisticProduct} 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`
variants: context.storefront.query('/** variants query */'),
});
}

function Product() {
const {product: originalProduct, 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);

// @ts-ignore
return <ProductMain product={product} />;
}
Loading

0 comments on commit e432533

Please sign in to comment.