@redotech/redo-hydrogen 1.2.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/types.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CartReturn } from '@shopify/hydrogen';
1
+ import { CartReturn, OptimisticCart } from '@shopify/hydrogen';
2
2
  import { ReactNode, DependencyList } from 'react';
3
3
  import { CartWithActionsDocs } from '@shopify/hydrogen-react/dist/types/cart-types';
4
4
  import { ProductVariant } from '@shopify/hydrogen-react/storefront-api-types';
@@ -14,7 +14,7 @@ interface RedoCoverageClient {
14
14
  get eligible(): boolean;
15
15
  get price(): number | undefined;
16
16
  get storeId(): string | undefined;
17
- get cart(): CartReturn | CartWithActionsDocs | undefined;
17
+ get cart(): CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
18
18
  get cartProduct(): CartProductVariantFragment | undefined;
19
19
  get cartAttribute(): CartAttributeKey | undefined;
20
20
  get errors(): RedoError[] | undefined;
@@ -30,7 +30,7 @@ type RedoContextValue = {
30
30
  loading: boolean;
31
31
  storeId?: string;
32
32
  cartInfoToEnable?: CartInfoToEnable;
33
- cart?: CartReturn | CartWithActionsDocs;
33
+ cart?: CartReturn | CartWithActionsDocs | OptimisticCart;
34
34
  errors?: RedoError[];
35
35
  };
36
36
  declare enum RedoErrorType {
@@ -45,14 +45,14 @@ type RedoError = {
45
45
  };
46
46
 
47
47
  declare const RedoProvider: ({ cart, storeId, children }: {
48
- cart: CartReturn | CartWithActionsDocs;
48
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart;
49
49
  storeId: string;
50
50
  children: ReactNode;
51
51
  }) => ReactNode;
52
52
  declare const useRedoCoverageClient: () => RedoCoverageClient;
53
53
 
54
54
  declare const RedoCheckoutButtons: (props: {
55
- cart: CartReturn | CartWithActionsDocs;
55
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart;
56
56
  children?: ReactNode;
57
57
  onClick?: (enabled: boolean) => void;
58
58
  }) => react_jsx_runtime.JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@redotech/redo-hydrogen",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Utilities to enable and disable Redo coverage on Hydrogen stores",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -26,6 +26,7 @@
26
26
  "@rollup/plugin-node-resolve": "^16.0.0",
27
27
  "@rollup/plugin-terser": "^0.4.4",
28
28
  "@rollup/plugin-typescript": "^12.1.2",
29
+ "@svgr/rollup": "^8.1.0",
29
30
  "@types/react": "^19.0.8",
30
31
  "nodemon": "^3.1.9",
31
32
  "react": "^18.3.1",
package/rollup.config.js CHANGED
@@ -4,6 +4,7 @@ import typescript from "@rollup/plugin-typescript";
4
4
  import dts from "rollup-plugin-dts";
5
5
  import terser from "@rollup/plugin-terser";
6
6
  import peerDepsExternal from "rollup-plugin-peer-deps-external";
7
+ import svgr from '@svgr/rollup';
7
8
 
8
9
  const packageJson = require("./package.json");
9
10
 
@@ -30,6 +31,7 @@ export default [
30
31
  commonjs(),
31
32
  typescript({ tsconfig: "./tsconfig.json" }),
32
33
  terser(),
34
+ svgr(),
33
35
  ],
34
36
  external: ["react", "react-dom"],
35
37
  },
@@ -1,9 +1,5 @@
1
1
  import React, { MouseEvent, ReactNode, useEffect, useState } from "react";
2
- import {
3
- CartForm,
4
- CartActionInput,
5
- CartReturn,
6
- } from "@shopify/hydrogen";
2
+ import { CartForm, CartActionInput, CartReturn, OptimisticCart } from "@shopify/hydrogen";
7
3
  import { useRedoCoverageClient } from "../providers/redo-coverage-client";
8
4
  import { CartInfoToEnable, RedoCoverageClient } from "../types";
9
5
  import { REDO_PUBLIC_API_HOSTNAME } from "../utils/security";
@@ -11,6 +7,9 @@ import { CurrencyCode } from "@shopify/hydrogen-react/storefront-api-types";
11
7
  import { CartWithActionsDocs } from "@shopify/hydrogen-react/dist/types/cart-types";
12
8
  import { getCartLines, isCartWithActionsDocs } from "../utils/cart";
13
9
 
10
+ import CircleSpinner from "../utils/circle-spinner.svg";
11
+ import { executeWithTimeout } from "../utils/timeout";
12
+
14
13
  type CheckoutButtonUIResponse = {
15
14
  html: string;
16
15
  css: string;
@@ -22,7 +21,7 @@ const getButtonsToShow = ({
22
21
  storeId
23
22
  }: {
24
23
  redoCoverageClient: RedoCoverageClient,
25
- cart: CartReturn | CartWithActionsDocs,
24
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart,
26
25
  storeId: string;
27
26
  }): Promise<CheckoutButtonUIResponse | null> => {
28
27
  return new Promise<CheckoutButtonUIResponse | null>((resolve, reject) => {
@@ -47,7 +46,7 @@ const getButtonsToShow = ({
47
46
  ui: json
48
47
  });
49
48
 
50
- if(!ui) {
49
+ if (!ui) {
51
50
  return reject(null);
52
51
  }
53
52
 
@@ -62,15 +61,15 @@ const applyButtonVariables = ({
62
61
  ui
63
62
  }: {
64
63
  redoCoverageClient: RedoCoverageClient,
65
- cart: CartReturn | CartWithActionsDocs,
64
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart,
66
65
  ui: CheckoutButtonUIResponse
67
66
  }): CheckoutButtonUIResponse | null => {
68
- if(!redoCoverageClient.eligible || !redoCoverageClient.price || !cart?.cost) {
67
+ if (!redoCoverageClient.eligible || !redoCoverageClient.price || !cart?.cost) {
69
68
  return null;
70
69
  }
71
70
 
72
- let currencyCode: CurrencyCode = cart.cost.totalAmount.currencyCode;
73
- if(currencyCode === 'XXX') {
71
+ let currencyCode: CurrencyCode = cart.cost.subtotalAmount.currencyCode;
72
+ if (currencyCode === 'XXX') {
74
73
  currencyCode = 'USD';
75
74
  }
76
75
 
@@ -78,9 +77,9 @@ const applyButtonVariables = ({
78
77
  const combinedPrice = new Intl.NumberFormat('en-US', {
79
78
  style: 'currency',
80
79
  currency: currencyCode
81
- }).format(Number(cart.cost.totalAmount.amount) + (cartContainsRedo ? 0 : redoCoverageClient.price));
80
+ }).format(Number(cart.cost.subtotalAmount.amount) + (cartContainsRedo ? 0 : redoCoverageClient.price));
82
81
 
83
- if(!combinedPrice || !combinedPrice.length || combinedPrice.includes('NaN')) {
82
+ if (!combinedPrice || !combinedPrice.length || combinedPrice.includes('NaN')) {
84
83
  return null;
85
84
  }
86
85
 
@@ -103,7 +102,7 @@ const findAncestor = (
103
102
  };
104
103
 
105
104
  const RedoCheckoutButtons = (props: {
106
- cart: CartReturn | CartWithActionsDocs;
105
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart;
107
106
  children?: ReactNode;
108
107
  onClick?: (enabled: boolean) => void;
109
108
  }) => {
@@ -112,23 +111,54 @@ const RedoCheckoutButtons = (props: {
112
111
  let checkoutUrl = redoCoverageClient.cart?.checkoutUrl || '/checkout';
113
112
  let [redoProductToAdd, setRedoProductToAdd] =
114
113
  useState<CartInfoToEnable | null>(null);
115
- let [checkoutButtonsUI, setCheckoutButtonsUI] = useState<CheckoutButtonUIResponse | null>(
116
- null
117
- );
114
+ let [checkoutButtonsUI, setCheckoutButtonsUI] =
115
+ useState<CheckoutButtonUIResponse | null>(null);
116
+
117
+ const [buttonPending, setButtonPending] = useState(false);
118
118
 
119
119
  useEffect(() => {
120
120
  (async () => {
121
- if(!redoCoverageClient.eligible || !cart || !redoCoverageClient.storeId) {
121
+ if (!redoCoverageClient.eligible || !cart || !redoCoverageClient.storeId) {
122
122
  return;
123
123
  }
124
124
 
125
125
  const buttons = await getButtonsToShow({ redoCoverageClient, cart, storeId: redoCoverageClient.storeId });
126
- if(buttons) {
126
+ if (buttons) {
127
127
  setCheckoutButtonsUI(buttons);
128
128
  }
129
129
  })();
130
130
  }, [cart, redoCoverageClient.eligible, redoCoverageClient.price, redoCoverageClient.storeId]);
131
131
 
132
+ /** To avoid the inevitable spammers trying to checkout faster by clicking over and over, between the time the promise resolves and the new tab opens (or errors) */
133
+ const DELAY_TO_ALLOW_CLICKING_AGAIN = 2000;
134
+ const TIMEOUT_FOR_CHECKOUTS = 8000;
135
+
136
+ const handleCoverageCheckoutClick = async (isCoverage: boolean) => {
137
+ if (!redoCoverageClient || !redoCoverageClient.enable || !redoCoverageClient.disable) {
138
+ console.error('Required redoCoverageClient methods not available');
139
+ return;
140
+ }
141
+
142
+ setButtonPending(true);
143
+ try {
144
+ const functionToCall = isCoverage ? redoCoverageClient.enable : redoCoverageClient.disable;
145
+ const result = await executeWithTimeout(
146
+ functionToCall(),
147
+ TIMEOUT_FOR_CHECKOUTS
148
+ );
149
+
150
+ if (props.onClick) {
151
+ await props.onClick(result);
152
+ }
153
+ } catch (e) {
154
+ console.error(e);
155
+ } finally {
156
+ setTimeout(() => {
157
+ setButtonPending(false);
158
+ }, DELAY_TO_ALLOW_CLICKING_AGAIN);
159
+ }
160
+ };
161
+
132
162
  const wrapperClickHandler = async (e: MouseEvent) => {
133
163
  let clickedElement = e.target as HTMLElement;
134
164
 
@@ -136,26 +166,21 @@ const RedoCheckoutButtons = (props: {
136
166
  return;
137
167
  }
138
168
 
139
- if (
140
- findAncestor(
141
- clickedElement,
142
- (el) => el.dataset?.target == "coverage-button"
143
- )
144
- ) {
145
- const attachResult = await redoCoverageClient.enable();
146
- if (props.onClick) {
147
- await props.onClick(attachResult);
148
- }
149
- window.location.href = checkoutUrl;
150
- } else if (
151
- findAncestor(
152
- clickedElement,
153
- (el) => el.dataset.target == "non-coverage-button"
154
- )
155
- ) {
156
- await redoCoverageClient.disable();
157
- if (props.onClick) {
158
- await props.onClick(false);
169
+ const isCoverageButton = findAncestor(
170
+ clickedElement,
171
+ (el) => el.dataset?.target == "coverage-button"
172
+ );
173
+
174
+ const isNonCoverageButton = findAncestor(
175
+ clickedElement,
176
+ (el) => el.dataset?.target == "non-coverage-button",
177
+ );
178
+
179
+ if (isCoverageButton || isNonCoverageButton) {
180
+ try {
181
+ await handleCoverageCheckoutClick(isCoverageButton ? true : false);
182
+ } catch (error) {
183
+ console.error('Failed to update coverage state:', error);
159
184
  }
160
185
  window.location.href = checkoutUrl;
161
186
  }
@@ -164,11 +189,32 @@ const RedoCheckoutButtons = (props: {
164
189
  return (
165
190
  <div>
166
191
  {checkoutButtonsUI ? (
167
- <div onClick={wrapperClickHandler}>
168
- {
169
- checkoutButtonsUI.css ? <style>{checkoutButtonsUI.css}</style> : ''
170
- }
171
- <div dangerouslySetInnerHTML={{ __html: checkoutButtonsUI.html }} />
192
+ <div onClick={wrapperClickHandler} style={{ position: "relative" }}>
193
+ {checkoutButtonsUI.css ? <style>{checkoutButtonsUI.css}</style> : ''}
194
+ <div
195
+ dangerouslySetInnerHTML={{ __html: checkoutButtonsUI.html }}
196
+ style={{
197
+ opacity: (buttonPending) ? 0.25 : 1,
198
+ transition: 'opacity 0.2s ease-in-out'
199
+ }}
200
+ />
201
+ {(buttonPending) && (
202
+ <div
203
+ style={{
204
+ position: "absolute",
205
+ top: 0,
206
+ left: 0,
207
+ width: "100%",
208
+ height: "100%",
209
+ display: "flex",
210
+ justifyContent: "center",
211
+ alignItems: "center",
212
+ zIndex: 1,
213
+ }}
214
+ >
215
+ <CircleSpinner />
216
+ </div>
217
+ )}
172
218
  </div>
173
219
  ) : (
174
220
  props.children
@@ -1,9 +1,9 @@
1
1
  import { useFetcher } from "@remix-run/react";
2
- import { CartReturn } from "@shopify/hydrogen";
3
- import { createContext, ReactNode, useContext, useEffect, useState } from "react";
2
+ import { CartReturn, OptimisticCart } from "@shopify/hydrogen";
3
+ import { createContext, ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
4
4
  import { CartProductVariantFragment, CartAttributeKey, CartInfoToEnable, RedoContextValue, RedoCoverageClient, RedoError, RedoErrorType } from "../types";
5
5
  import { REDO_PUBLIC_API_HOSTNAME } from "../utils/security";
6
- import { addProductToCartIfNeeded, removeProductFromCartIfNeeded, setCartRedoEnabledAttribute, useFetcherWithPromise, isCartWithActionsDocs, getCartLines } from "../utils/cart";
6
+ import { addProductToCartIfNeeded, removeProductFromCartIfNeeded, setCartRedoEnabledAttribute, useFetcherWithPromise, isCartWithActionsDocs, getCartLines, useWaitCartIdle, isOptimisticCart } from "../utils/cart";
7
7
  import { CartWithActionsDocs } from "@shopify/hydrogen-react/dist/types/cart-types";
8
8
 
9
9
  const DEFAULT_REDO_CONTEXT_VALUE: RedoContextValue = {
@@ -18,7 +18,7 @@ const RedoProvider = ({
18
18
  storeId,
19
19
  children
20
20
  }: {
21
- cart: CartReturn | CartWithActionsDocs,
21
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart,
22
22
  storeId: string,
23
23
  children: ReactNode,
24
24
  }): ReactNode => {
@@ -37,7 +37,7 @@ const RedoProvider = ({
37
37
  }
38
38
 
39
39
  useEffect(() => {
40
- if(!cart || !storeId) {
40
+ if(!cart || !storeId || isOptimisticCart(cart)) {
41
41
  return;
42
42
  }
43
43
 
@@ -141,17 +141,19 @@ const RedoProvider = ({
141
141
  const useRedoCoverageClient = (): RedoCoverageClient => {
142
142
  const redoContext = useContext(RedoContext);
143
143
  const fetcher = useFetcherWithPromise();
144
+ const waitCartIdle = useWaitCartIdle(redoContext.cart);
144
145
 
145
146
  useEffect(() => {
146
147
  if(redoContext.loading || !redoContext.cartInfoToEnable) {
147
148
  return;
148
149
  }
149
150
  removeProductFromCartIfNeeded({
150
- fetcher,
151
151
  cart: redoContext.cart,
152
+ fetcher,
153
+ waitCartIdle,
152
154
  cartInfoToEnable: redoContext.cartInfoToEnable
153
155
  });
154
- }, [redoContext.loading])
156
+ }, [redoContext.loading]);
155
157
 
156
158
  return {
157
159
  enable: async () => {
@@ -160,12 +162,14 @@ const useRedoCoverageClient = (): RedoCoverageClient => {
160
162
  }
161
163
  let addProductResult = await addProductToCartIfNeeded({
162
164
  fetcher,
165
+ waitCartIdle,
163
166
  cart: redoContext.cart,
164
167
  cartInfoToEnable: redoContext.cartInfoToEnable,
165
168
  });
166
169
  await setCartRedoEnabledAttribute({
167
170
  cart: redoContext.cart,
168
171
  fetcher,
172
+ waitCartIdle,
169
173
  cartInfoToEnable: redoContext.cartInfoToEnable,
170
174
  enabled: true
171
175
  });
@@ -177,12 +181,14 @@ const useRedoCoverageClient = (): RedoCoverageClient => {
177
181
  }
178
182
  await removeProductFromCartIfNeeded({
179
183
  fetcher,
184
+ waitCartIdle,
180
185
  cart: redoContext.cart,
181
186
  cartInfoToEnable: redoContext.cartInfoToEnable
182
187
  });
183
188
  await setCartRedoEnabledAttribute({
184
189
  cart: redoContext.cart,
185
190
  fetcher,
191
+ waitCartIdle,
186
192
  cartInfoToEnable: redoContext.cartInfoToEnable,
187
193
  enabled: false
188
194
  });
package/src/svg.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ declare module '*.svg' {
2
+ const content: string;
3
+ export default content;
4
+ }
package/src/types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { CartReturn } from "@shopify/hydrogen";
1
+ import { CartReturn, OptimisticCart } from "@shopify/hydrogen";
2
2
  import { CartWithActionsDocs } from "@shopify/hydrogen-react/dist/types/cart-types";
3
3
  import { ProductVariant } from "@shopify/hydrogen-react/storefront-api-types";
4
4
 
@@ -16,7 +16,7 @@ interface RedoCoverageClient {
16
16
  get eligible(): boolean;
17
17
  get price(): number | undefined;
18
18
  get storeId(): string | undefined;
19
- get cart(): CartReturn | CartWithActionsDocs | undefined;
19
+ get cart(): CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
20
20
  get cartProduct(): CartProductVariantFragment | undefined;
21
21
  get cartAttribute(): CartAttributeKey | undefined;
22
22
  get errors(): RedoError[] | undefined;
@@ -34,7 +34,7 @@ type RedoContextValue = {
34
34
  loading: boolean,
35
35
  storeId?: string,
36
36
  cartInfoToEnable?: CartInfoToEnable,
37
- cart?: CartReturn | CartWithActionsDocs,
37
+ cart?: CartReturn | CartWithActionsDocs | OptimisticCart,
38
38
  errors?: RedoError[],
39
39
  };
40
40
 
package/src/utils/cart.ts CHANGED
@@ -1,47 +1,84 @@
1
1
  import { FetcherWithComponents, useFetcher } from "@remix-run/react";
2
2
  import { CartInfoToEnable } from "../types";
3
- import { CartForm, CartReturn } from "@shopify/hydrogen";
3
+ import { CartForm, CartReturn, OptimisticCart, OptimisticCartLine } from "@shopify/hydrogen";
4
4
  import type { AppData } from '@remix-run/react/dist/data';
5
- import React from 'react'
5
+ import React, { useCallback, useEffect, useRef } from 'react'
6
6
  import { CartWithActionsDocs } from "@shopify/hydrogen-react/dist/types/cart-types";
7
7
  import { CartLine, ComponentizableCartLine } from "@shopify/hydrogen-react/storefront-api-types";
8
8
 
9
9
  const DEFAULT_REDO_ENABLED_CART_ATTRIBUTE = 'redo_opted_in_from_cart';
10
10
 
11
- const isCartWithActionsDocs = (cart: CartReturn | CartWithActionsDocs): cart is CartWithActionsDocs => {
11
+ const isCartWithActionsDocs = (cart: CartReturn | CartWithActionsDocs| OptimisticCart): cart is CartWithActionsDocs => {
12
12
  return (Array.isArray(cart.lines) && 'linesAdd' in cart && typeof cart.linesAdd === 'function');
13
13
  }
14
14
 
15
- const getCartLines = (cart: CartReturn | CartWithActionsDocs): Array<CartLine | ComponentizableCartLine> => {
16
- if(isCartWithActionsDocs(cart)) {
15
+ const getCartLines = (cart: CartReturn | CartWithActionsDocs | OptimisticCart): Array<CartLine | ComponentizableCartLine> => {
16
+ if (isOptimisticCart(cart)) {
17
+ return cart.lines.nodes;
18
+ } else if (isCartWithActionsDocs(cart)) {
17
19
  return cart.lines;
18
20
  } else {
19
21
  return cart.lines.nodes ?? cart.lines.edges.map((edge) => edge.node);
20
22
  }
21
23
  }
22
24
 
23
- const waitUntilCartIdle = (cart: CartWithActionsDocs): Promise<void> => {
25
+ // https://shopify.dev/docs/api/hydrogen/2025-01/hooks/useoptimisticcart
26
+ const isOptimisticCart = (cart: CartReturn | CartWithActionsDocs | OptimisticCart): cart is OptimisticCart => {
27
+ return 'isOptimistic' in cart && (cart.isOptimistic ?? false);
28
+ }
29
+
30
+ const isRedoInCart = ({
31
+ cart
32
+ }: {
33
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart
34
+ }): boolean => {
35
+ if(!cart) {
36
+ return false;
37
+ }
38
+
39
+ return getCartLines(cart).some((cartLine) => {
40
+ return cartLine.merchandise.product.vendor === 're:do';
41
+ });
42
+ }
43
+
44
+ const waitForConditionsMetOrTimeout = ({
45
+ conditions,
46
+ timeoutMs
47
+ }: {
48
+ conditions: (() => boolean)[];
49
+ timeoutMs: number;
50
+ }): Promise<boolean> => {
24
51
  return new Promise((resolve, reject) => {
52
+ let start = Date.now();
25
53
  let interval = setInterval(() => {
26
- if(cart.status === 'idle') {
54
+ if((Date.now() - start) > timeoutMs) {
55
+ clearInterval(interval);
56
+ return resolve(false);
57
+ }
58
+
59
+ let conditionsMet = conditions.every((conditionCallback) => conditionCallback());
60
+
61
+ if(conditionsMet) {
27
62
  clearInterval(interval);
28
- return resolve();
63
+ return resolve(true);
29
64
  }
30
65
  }, 100);
31
- });
66
+ })
32
67
  }
33
68
 
34
69
  const addProductToCartIfNeeded = async ({
35
70
  cart,
36
71
  fetcher,
72
+ waitCartIdle,
37
73
  cartInfoToEnable
38
74
  }: {
39
- cart: CartReturn | CartWithActionsDocs | undefined,
75
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined,
40
76
  fetcher: FetcherWithComponents<unknown>,
77
+ waitCartIdle: WaitCartIdleCallback;
41
78
  cartInfoToEnable: CartInfoToEnable
42
79
  }) => {
43
80
  if(!cart) {
44
- return await addProductToCart({ cart, fetcher, cartInfoToEnable });
81
+ return await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
45
82
  }
46
83
 
47
84
  const redoProductsInCart = getCartLines(cart).filter((cartLine) => {
@@ -51,15 +88,15 @@ const addProductToCartIfNeeded = async ({
51
88
  return cartLine.merchandise.id === `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`;
52
89
  });
53
90
  if(redoProductsInCart.length === 0) {
54
- return await addProductToCart({ cart, fetcher, cartInfoToEnable });
91
+ return await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
55
92
  } else if (redoProductsInCart.length === 1 && correctRedoProductInCart.length === 1 && correctRedoProductInCart[0].quantity === 1) {
56
93
  // No action needed
57
94
  return;
58
95
  } else {
59
96
  let isSuccess = true;
60
97
 
61
- await removeLinesFromCart({ cart, fetcher, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
62
- await addProductToCart({ cart, fetcher, cartInfoToEnable });
98
+ await removeLinesFromCart({ cart, fetcher, waitCartIdle, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
99
+ await addProductToCart({ cart, fetcher, waitCartIdle, cartInfoToEnable });
63
100
 
64
101
  return;
65
102
  }
@@ -68,10 +105,12 @@ const addProductToCartIfNeeded = async ({
68
105
  const removeLinesFromCart = async ({
69
106
  cart,
70
107
  fetcher,
108
+ waitCartIdle,
71
109
  lineIds
72
110
  }: {
73
- cart: CartReturn | CartWithActionsDocs | undefined;
111
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
74
112
  fetcher: FetcherWithComponents<unknown>;
113
+ waitCartIdle: WaitCartIdleCallback;
75
114
  lineIds: string[];
76
115
  }) => {
77
116
  const formInput = {
@@ -83,7 +122,7 @@ const removeLinesFromCart = async ({
83
122
 
84
123
  if(cart && isCartWithActionsDocs(cart)) {
85
124
  cart.linesRemove(lineIds);
86
- await waitUntilCartIdle(cart);
125
+ await waitCartIdle();
87
126
  } else {
88
127
  await fetcher.submit(
89
128
  {
@@ -97,10 +136,12 @@ const removeLinesFromCart = async ({
97
136
  const removeProductFromCartIfNeeded = async ({
98
137
  cart,
99
138
  fetcher,
139
+ waitCartIdle,
100
140
  cartInfoToEnable
101
141
  }: {
102
- cart: CartReturn | CartWithActionsDocs | undefined,
142
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined,
103
143
  fetcher: FetcherWithComponents<unknown>,
144
+ waitCartIdle: WaitCartIdleCallback
104
145
  cartInfoToEnable: CartInfoToEnable
105
146
  }) => {
106
147
  if(!cart) {
@@ -113,24 +154,25 @@ const removeProductFromCartIfNeeded = async ({
113
154
  });
114
155
 
115
156
  if(redoProductsInCart.length !== 0) {
116
- await removeLinesFromCart({ cart, fetcher, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
157
+ await removeLinesFromCart({ cart, fetcher, waitCartIdle, lineIds: redoProductsInCart.map((cartLine) => cartLine.id) });
117
158
  } else {
118
159
  }
119
160
  };
120
161
 
121
162
  const addProductToCart = async ({
163
+ waitCartIdle,
122
164
  cart,
123
165
  fetcher,
124
166
  cartInfoToEnable,
125
167
  }: {
126
- cart: CartReturn | CartWithActionsDocs | undefined,
168
+ waitCartIdle: WaitCartIdleCallback;
169
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined,
127
170
  fetcher: FetcherWithComponents<unknown>,
128
171
  cartInfoToEnable: CartInfoToEnable
129
172
  }) => {
130
173
  const redoProductLine = {
131
174
  "merchandiseId": `gid://shopify/ProductVariant/${cartInfoToEnable.variantId}`,
132
175
  "quantity": 1,
133
- "selectedVariant": cartInfoToEnable.selectedVariant
134
176
  };
135
177
 
136
178
  const formInput = {
@@ -144,7 +186,7 @@ const addProductToCart = async ({
144
186
 
145
187
  if(cart && isCartWithActionsDocs(cart)) {
146
188
  cart.linesAdd([redoProductLine]);
147
- await waitUntilCartIdle(cart);
189
+ await waitCartIdle();
148
190
  } else {
149
191
  await fetcher.submit(
150
192
  {
@@ -158,11 +200,13 @@ const addProductToCart = async ({
158
200
  const setCartRedoEnabledAttribute = async ({
159
201
  cart,
160
202
  fetcher,
203
+ waitCartIdle,
161
204
  cartInfoToEnable,
162
205
  enabled
163
206
  }: {
164
- cart: CartReturn | CartWithActionsDocs | undefined;
207
+ cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined;
165
208
  fetcher: FetcherWithComponents<unknown>;
209
+ waitCartIdle: WaitCartIdleCallback;
166
210
  cartInfoToEnable: CartInfoToEnable | null;
167
211
  enabled: boolean;
168
212
  }) => {
@@ -182,7 +226,7 @@ const setCartRedoEnabledAttribute = async ({
182
226
 
183
227
  if(cart && isCartWithActionsDocs(cart)) {
184
228
  cart.cartAttributesUpdate([redoCartAttribute]);
185
- await waitUntilCartIdle(cart);
229
+ await waitCartIdle();
186
230
  } else {
187
231
  await fetcher.submit(
188
232
  {
@@ -233,12 +277,63 @@ function useFetcherWithPromise<TData = AppData>(opts?: Parameters<typeof useFetc
233
277
  return { ...fetcher, submit }
234
278
  }
235
279
 
280
+ type WaitCartIdleCallback = () => Promise<CartReturn | CartWithActionsDocs | OptimisticCart>;
281
+
282
+ // This function allows us to await a cart idle state without breaking React rules.
283
+ // It returns a function, which returns a promise, which will resolve once the cart value passed in reaches an idle state.
284
+ // Not intended for use with CartReturn, but will accept that value if passed in to avoid breaking rules of hooks
285
+ const useWaitCartIdle = (cart: CartReturn | CartWithActionsDocs | OptimisticCart | undefined) => {
286
+ const resolveRef = useRef<any>(null)
287
+ const promiseRef = useRef<any>(null)
288
+
289
+ if (!promiseRef.current) {
290
+ promiseRef.current = new Promise<CartReturn | CartWithActionsDocs | OptimisticCart>((resolve) => {
291
+ resolveRef.current = resolve
292
+ })
293
+ }
294
+
295
+ const resetResolver = useCallback(() => {
296
+ promiseRef.current = new Promise((resolve) => {
297
+ resolveRef.current = resolve
298
+ })
299
+ }, [promiseRef, resolveRef]);
300
+
301
+ const waitCartIdle = useCallback(
302
+ async () => {
303
+ return promiseRef.current
304
+ },
305
+ [cart, promiseRef]
306
+ )
307
+
308
+ useEffect(() => {
309
+ if(!cart) {
310
+ return;
311
+ }
312
+ if(!isCartWithActionsDocs(cart)) {
313
+ // Wrong type of cart. Just resolve.
314
+ resolveRef.current?.(cart);
315
+ resetResolver();
316
+ } else if(cart.status === 'idle') {
317
+ resolveRef.current?.(cart)
318
+ resetResolver();
319
+ }
320
+ }, [cart, resetResolver]);
321
+
322
+ return waitCartIdle;
323
+ }
324
+
325
+ export type {
326
+ WaitCartIdleCallback
327
+ }
328
+
236
329
  export {
237
330
  DEFAULT_REDO_ENABLED_CART_ATTRIBUTE,
238
331
  addProductToCartIfNeeded,
239
332
  removeProductFromCartIfNeeded,
240
333
  setCartRedoEnabledAttribute,
241
334
  useFetcherWithPromise,
335
+ useWaitCartIdle,
242
336
  isCartWithActionsDocs,
243
- getCartLines
337
+ getCartLines,
338
+ isOptimisticCart
244
339
  };