Skip to content
This repository has been archived by the owner on Feb 23, 2024. It is now read-only.

Commit

Permalink
Rest API batching support (2) (#4075)
Browse files Browse the repository at this point in the history
* Add batch route

* Register batch route

* Allow batching on writable endpoints

* Batch in client

* Batch non-GET requests

* Batching support with typescript defs

* Remove unused hook

* Prevent multiple fragment updates

* Only use batch route if detected

* Correct var name

* Move nonce check to validate_callback so it runs before requests are completed

* remove unused imports

* updateCartFragments function as const

* Add phpunit tests for batching functionality

* Reduce batch delay

* increase timeout

* Update isCartUpdatePostRequest for batch support

* Update Endpoint used in test

* Move nonce check back inline - custom headers are not returned otherwise

* Fix error handling

* Back to 30s

* Update assets/js/middleware/cart-update.ts

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

* whitespace

Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>
  • Loading branch information
mikejolley and opr committed Apr 20, 2021
1 parent 71aeaff commit fdc74b8
Show file tree
Hide file tree
Showing 33 changed files with 639 additions and 115 deletions.
12 changes: 0 additions & 12 deletions assets/js/atomic/blocks/product-elements/button/block.js
Expand Up @@ -4,13 +4,11 @@
import PropTypes from 'prop-types';
import classnames from 'classnames';
import { __, _n, sprintf } from '@wordpress/i18n';
import { useEffect, useRef } from '@wordpress/element';
import {
useStoreEvents,
useStoreAddToCart,
} from '@woocommerce/base-context/hooks';
import { decodeEntities } from '@wordpress/html-entities';
import { triggerFragmentRefresh } from '@woocommerce/base-utils';
import {
useInnerBlockLayoutContext,
useProductDataContext,
Expand Down Expand Up @@ -54,7 +52,6 @@ const Block = ( { className } ) => {
};

const AddToCartButton = ( { product } ) => {
const firstMount = useRef( true );
const {
id,
permalink,
Expand All @@ -66,15 +63,6 @@ const AddToCartButton = ( { product } ) => {
const { dispatchStoreEvent } = useStoreEvents();
const { cartQuantity, addingToCart, addToCart } = useStoreAddToCart( id );

useEffect( () => {
// Avoid running on first mount when cart quantity is first set.
if ( firstMount.current ) {
firstMount.current = false;
return;
}
triggerFragmentRefresh();
}, [ cartQuantity ] );

const addedToCart = Number.isFinite( cartQuantity ) && cartQuantity > 0;
const allowAddToCart = ! hasOptions && isPurchasable && isInStock;
const buttonAriaLabel = decodeEntities(
Expand Down
Expand Up @@ -73,9 +73,7 @@ export const useStoreCartItemQuantity = (
Number.isFinite( previousDebouncedQuantity ) &&
previousDebouncedQuantity !== debouncedQuantity
) {
changeCartItemQuantity( cartItemKey, debouncedQuantity ).then(
triggerFragmentRefresh
);
changeCartItemQuantity( cartItemKey, debouncedQuantity );
}
}, [
cartItemKey,
Expand Down
Expand Up @@ -5,7 +5,6 @@ import { __ } from '@wordpress/i18n';
import triggerFetch from '@wordpress/api-fetch';
import { useEffect, useCallback, useState } from '@wordpress/element';
import { decodeEntities } from '@wordpress/html-entities';
import { triggerFragmentRefresh } from '@woocommerce/base-utils';

/**
* Internal dependencies
Expand Down Expand Up @@ -110,7 +109,6 @@ const FormSubmit = () => {
}
dispatchActions.setAfterProcessing( response );
setIsSubmitting( false );
triggerFragmentRefresh();
} );
} )
.catch( ( error ) => {
Expand Down
1 change: 0 additions & 1 deletion assets/js/base/hooks/index.js
Expand Up @@ -4,4 +4,3 @@ export * from './use-position-relative-to-viewport';
export * from './use-previous';
export * from './use-shallow-equal';
export * from './use-throw-error';
export * from './use-trigger-fragment-refresh';
22 changes: 0 additions & 22 deletions assets/js/base/hooks/use-trigger-fragment-refresh.ts

This file was deleted.

12 changes: 11 additions & 1 deletion assets/js/base/utils/legacy-events.js
Expand Up @@ -34,11 +34,21 @@ export const dispatchEvent = (
}
};

let fragmentRequestTimeoutId;

// This is a hack to trigger cart updates till we migrate to block based cart
// that relies on the store, see
// https://github.com/woocommerce/woocommerce-gutenberg-products-block/issues/1247
export const triggerFragmentRefresh = () => {
dispatchEvent( 'wc_fragment_refresh', { bubbles: true, cancelable: true } );
if ( fragmentRequestTimeoutId ) {
clearTimeout( fragmentRequestTimeoutId );
}
fragmentRequestTimeoutId = setTimeout( () => {
dispatchEvent( 'wc_fragment_refresh', {
bubbles: true,
cancelable: true,
} );
}, 50 );
};

/**
Expand Down
1 change: 1 addition & 0 deletions assets/js/data/cart/action-types.ts
Expand Up @@ -10,4 +10,5 @@ export const ACTION_TYPES = {
RECEIVE_REMOVED_ITEM: 'RECEIVE_REMOVED_ITEM',
UPDATING_CUSTOMER_DATA: 'UPDATING_CUSTOMER_DATA',
UPDATING_SELECTED_SHIPPING_RATE: 'UPDATING_SELECTED_SHIPPING_RATE',
UPDATE_LEGACY_CART_FRAGMENTS: 'UPDATE_LEGACY_CART_FRAGMENTS',
} as const;
19 changes: 18 additions & 1 deletion assets/js/data/cart/actions.ts
Expand Up @@ -157,6 +157,14 @@ export const shippingRatesBeingSelected = ( isResolving: boolean ) =>
isResolving,
} as const );

/**
* Returns an action object for updating legacy cart fragments.
*/
export const updateCartFragments = () =>
( {
type: types.UPDATE_LEGACY_CART_FRAGMENTS,
} as const );

/**
* Applies a coupon code and either invalidates caches, or receives an error if
* the coupon cannot be applied.
Expand All @@ -181,6 +189,7 @@ export function* applyCoupon(

yield receiveCart( response );
yield receiveApplyingCoupon( '' );
yield updateCartFragments();
} catch ( error ) {
yield receiveError( error );
yield receiveApplyingCoupon( '' );
Expand Down Expand Up @@ -221,6 +230,7 @@ export function* removeCoupon(

yield receiveCart( response );
yield receiveRemovingCoupon( '' );
yield updateCartFragments();
} catch ( error ) {
yield receiveError( error );
yield receiveRemovingCoupon( '' );
Expand Down Expand Up @@ -263,6 +273,7 @@ export function* addItemToCart(
} );

yield receiveCart( response );
yield updateCartFragments();
} catch ( error ) {
yield receiveError( error );

Expand Down Expand Up @@ -292,12 +303,16 @@ export function* removeItemFromCart(

try {
const { response } = yield apiFetchWithHeaders( {
path: `/wc/store/cart/remove-item/?key=${ cartItemKey }`,
path: `/wc/store/cart/remove-item`,
data: {
key: cartItemKey,
},
method: 'POST',
cache: 'no-store',
} );

yield receiveCart( response );
yield updateCartFragments();
} catch ( error ) {
yield receiveError( error );

Expand Down Expand Up @@ -341,6 +356,7 @@ export function* changeCartItemQuantity(
} );

yield receiveCart( response );
yield updateCartFragments();
} catch ( error ) {
yield receiveError( error );

Expand Down Expand Up @@ -452,4 +468,5 @@ export type CartAction = ReturnOrGeneratorYieldUnion<
| typeof removeItemFromCart
| typeof changeCartItemQuantity
| typeof addItemToCart
| typeof updateCartFragments
>;
15 changes: 15 additions & 0 deletions assets/js/data/cart/controls.js
@@ -0,0 +1,15 @@
/**
* External dependencies
*/
import { triggerFragmentRefresh } from '@woocommerce/base-utils';

/**
* Default export for registering the controls with the store.
*
* @return {Object} An object with the controls to register with the store on the controls property of the registration object.
*/
export const controls = {
UPDATE_LEGACY_CART_FRAGMENTS() {
triggerFragmentRefresh();
},
};
151 changes: 125 additions & 26 deletions assets/js/data/shared-controls.ts
Expand Up @@ -3,6 +3,17 @@
*/
import { __ } from '@wordpress/i18n';
import triggerFetch, { APIFetchOptions } from '@wordpress/api-fetch';
import DataLoader from 'dataloader';
import { getSetting } from '@woocommerce/settings';

/**
* Internal dependencies
*/
import {
assertBatchResponseIsValid,
assertResponseIsValid,
ApiResponse,
} from './types';

/**
* Dispatched a control action for triggering an api fetch call with no parsing.
Expand All @@ -16,6 +27,8 @@ export const apiFetchWithHeaders = ( options: APIFetchOptions ) =>
options,
} as const );

const EMPTY_OBJECT = {};

/**
* Error thrown when JSON cannot be parsed.
*/
Expand Down Expand Up @@ -50,6 +63,56 @@ const setNonceOnFetch = ( headers: Headers ): void => {
}
};

/**
* Trigger a fetch from the API using the batch endpoint.
*/
const triggerBatchFetch = ( keys: readonly APIFetchOptions[] ) => {
return triggerFetch( {
path: `/wc/store/batch`,
method: 'POST',
data: {
requests: keys.map( ( request: APIFetchOptions ) => {
return {
...request,
body: request?.data,
};
} ),
},
} ).then( ( response: unknown ) => {
assertBatchResponseIsValid( response );
return keys.map(
( key, index: number ) =>
response.responses[ index ] || EMPTY_OBJECT
);
} );
};

/**
* In ms, how long we should wait for requests to batch.
*
* DataLoader collects all requests over this window of time (and as a consequence, adds this amount of latency).
*/
const triggerBatchFetchDelay = 300;

/**
* DataLoader instance for triggerBatchFetch.
*/
const triggerBatchFetchLoader = new DataLoader( triggerBatchFetch, {
batchScheduleFn: ( callback: () => void ) =>
setTimeout( callback, triggerBatchFetchDelay ),
cache: false,
maxBatchSize: 25,
} );

/**
* Trigger a fetch from the API using the batch endpoint.
*
* @param {APIFetchOptions} request Request object containing API request.
*/
const batchFetch = async ( request: APIFetchOptions ) => {
return await triggerBatchFetchLoader.load( request );
};

/**
* Default export for registering the controls with the store.
*
Expand All @@ -60,38 +123,74 @@ export const controls = {
API_FETCH_WITH_HEADERS: ( {
options,
}: ReturnType< typeof apiFetchWithHeaders > ): Promise< unknown > => {
const routes = getSetting( 'restApiRoutes' );

return new Promise( ( resolve, reject ) => {
triggerFetch( { ...options, parse: false } )
.then( ( fetchResponse ) => {
fetchResponse
.json()
.then( ( response ) => {
resolve( {
response,
headers: fetchResponse.headers,
} );
setNonceOnFetch( fetchResponse.headers );
} )
.catch( () => {
reject( invalidJsonError );
} );
} )
.catch( ( errorResponse ) => {
setNonceOnFetch( errorResponse.headers );
if ( typeof errorResponse.json === 'function' ) {
// Parse error response before rejecting it.
errorResponse
// GET Requests cannot be batched.
if (
! options.method ||
options.method === 'GET' ||
! routes[ '/wc/store' ].includes( '/wc/store/batch' )
) {
// Parse is disabled here to avoid returning just the body--we also need headers.
triggerFetch( { ...options, parse: false } )
.then( ( fetchResponse ) => {
fetchResponse
.json()
.then( ( error: unknown ) => {
reject( error );
.then( ( response ) => {
resolve( {
response,
headers: fetchResponse.headers,
} );
setNonceOnFetch( fetchResponse.headers );
} )
.catch( () => {
reject( invalidJsonError );
} );
} else {
reject( errorResponse.message );
}
} );
} )
.catch( ( errorResponse ) => {
setNonceOnFetch( errorResponse.headers );
if ( typeof errorResponse.json === 'function' ) {
// Parse error response before rejecting it.
errorResponse
.json()
.then( ( error: unknown ) => {
reject( error );
} )
.catch( () => {
reject( invalidJsonError );
} );
} else {
reject( errorResponse.message );
}
} );
} else {
batchFetch( options )
.then( ( response: ApiResponse ) => {
assertResponseIsValid( response );

if ( response.status >= 200 && response.status < 300 ) {
resolve( {
response: response.body,
headers: response.headers,
} );
setNonceOnFetch( response.headers );
}

// Status code indicates error.
throw response;
} )
.catch( ( errorResponse: ApiResponse ) => {
if ( errorResponse.headers ) {
setNonceOnFetch( errorResponse.headers );
}
if ( errorResponse.body ) {
reject( errorResponse.body );
} else {
reject();
}
} );
}
} );
},
};

0 comments on commit fdc74b8

Please sign in to comment.