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

improve responsiveness when setting cart item quantities #1864

Merged
merged 15 commits into from
Mar 9, 2020

Conversation

haszari
Copy link
Member

@haszari haszari commented Mar 4, 2020

Fixes #1850

This PR improves how cart line item quantities work, allowing the user to increase/decrease quantity, and defers the server update to reduce unnecessary network requests.

  • The generator action for persisting quantity (changeCartItemQuantity) is now simpler. It doesn't set the item as pending, so the UI stays enabled and available when API requests are in progress.
  • Added local state in CartLineItemRow component so the quantity in the UI updates immediately.
  • The UI quantity value is debounced and then persisted when stable (via changeCartItemQuantity).

How to test the changes in this Pull Request:

  1. Add cart block to a page and publish, add some stuff to cart.
  2. Change item quantity using quantity picker - plus/minus buttons and text edit.
  3. Confirm that quantities correctly persist and the UI is responsive to user input at all times.

@haszari haszari changed the title improve responsiveness when setting cart item quantities (in progress) improve responsiveness when setting cart item quantities Mar 5, 2020
@haszari haszari self-assigned this Mar 5, 2020
@haszari haszari added skip-changelog PRs that you don't want to appear in the changelog. status: needs review labels Mar 5, 2020
@haszari haszari added this to the Future Release milestone Mar 5, 2020
@haszari haszari marked this pull request as ready for review March 5, 2020 02:52
@haszari haszari requested a review from a team as a code owner March 5, 2020 02:52
@haszari haszari requested review from Aljullu, senadir and nerrad and removed request for a team March 5, 2020 02:52
@haszari
Copy link
Member Author

haszari commented Mar 5, 2020

Note: the E2E test failure is not due to this branch, this is covered by #1869.

@senadir senadir force-pushed the fix/debouce-quantity-changes branch from 3f1fd68 to 673a004 Compare March 5, 2020 17:45
Copy link
Member

@senadir senadir left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

overall this is looking good, and it tests good, some points that could be cleaned before merging

@@ -68,9 +83,9 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
<td className="wc-block-cart-item__quantity">
<QuantitySelector
disabled={ itemQuantityDisabled }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and this, also other occurrence of disable

@@ -43,6 +44,20 @@ const CartLineItemRow = ( { lineItem = {} } ) => {
isPending: itemQuantityDisabled,
} = useStoreCartItem( key );

// Store item quantity in local state so the UI can update independently
// of store/server updates.
const [ localQuantity, updateLocalQuantity ] = useState( quantity );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

any reason why other components are not using localQuantity instead of quantity it should make other updates immediate (like saving and item total), the server can later update.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I'll look at that :)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added this as part of the recent changes, definitely makes it better seeing the discount/totals update live.

Comment on lines 55 to 59
useEffect( () => {
if ( key ) {
changeQuantity( debouncedQuantity );
}
}, [ key, debouncedQuantity ] );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for now, this triggers an update on initial render, we don’t need that, also there is no need to check for key and watch it since it’s stable and required for this component to work, so we can update this to avoid extra render+call for each item.

Suggested change
useEffect( () => {
if ( key ) {
changeQuantity( debouncedQuantity );
}
}, [ key, debouncedQuantity ] );
useEffect( () => {
if ( quantity !== debouncedQuantity ) {
changeQuantity( debouncedQuantity );
}
}, [ debouncedQuantity ] );

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was needed to prevent an issue in early renders - component rendering with no/invalid state. But perhaps that's an issue higher up, I'll investigate.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, looks like this was a remnant/workaround from a different iteration. I've fixed this here 960357c (of course may change if I refactor the state/debounce).

@haszari haszari force-pushed the fix/debouce-quantity-changes branch from 673a004 to 8ef90c9 Compare March 5, 2020 23:05
@haszari
Copy link
Member Author

haszari commented Mar 5, 2020

Rebased to fix conflicts.

@haszari
Copy link
Member Author

haszari commented Mar 6, 2020

When testing this I saw an issue - shopper can increase quantity past the stock level. #1887

When we fix that it may impact some of this - e.g. if the debounced API request returns an error we need to overwrite the optimistic changes in the UI and show a notice/message.

( select, { dispatch } ) => {
// Store quantity in hook state. This is used to keep the UI
// updated while server request is updated.
const [ quantity, changeQuantity ] = useState( cartItem?.quantity );
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to add the careful cartItem?.quantity optional chain operator here because this hook fires in the editor, causing JS error. I think this is because the block is using preview data but the hook still loads real cart data.

I think we need to fix this issue (as follow up) - the cart block shouldn't pull real data in the editor at all. Not sure of the best way to do this (e.g. shouldSelect, isEditor props, or maybe we can set "isEditor" in context somewhere so hooks can transparently support this. cc @nerrad

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya this is because cartItems could change. If you implement my suggestion for deriving cartItem via useEffect then that should take care of this issue.

@haszari
Copy link
Member Author

haszari commented Mar 6, 2020

This is ready for review again @senadir @nerrad.

I've now moved the debounce to the hook and it feels quite neat and tidy this way. The part that was confusing me is that updating quantity on the server is a side effect of updating the component / hook, which feels strange – but maybe a tiny bit less strange when it's not hidden in a hook. I'm on the fence about how closely this hook is "coupled" to cart line item component, but we can keep an eye on that as we iterate.

I worked around an issue with the hook firing in the editor – I haven't logged a follow up issue for that yet. See comment.

Comment on lines 25 to 27
export const useStoreCartItem = ( cartItemKey ) => {
const { cartItems, cartIsLoading } = useStoreCart();
const cartItem = cartItems.filter( ( item ) => item.key === cartItemKey );
const cartItem = cartItems.find( ( item ) => item.key === cartItemKey );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can avoid this by simply passing the cartItems to the cart, this should allow us to reload the data again (since we initially loaded it anyway), however, I'm not fully sure this is the best approach (do we want the hook to get the full item, or simply they key, I think it should be either not passing anything or passing everything).

Suggested change
export const useStoreCartItem = ( cartItemKey ) => {
const { cartItems, cartIsLoading } = useStoreCart();
const cartItem = cartItems.filter( ( item ) => item.key === cartItemKey );
const cartItem = cartItems.find( ( item ) => item.key === cartItemKey );
export const useStoreCartItem = ( cartItem ) => {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we can avoid this by simply passing the cartItems to the cart, this should allow us to reload the data again (since we initially loaded it anyway)

This sounds a bit confusing initially, but I think what you're suggesting here is to avoid an unnecessary request triggered by implementing useStoreCart, you have useStoreCartItem receive the cartItem directly and then it doesn't need to implement useStoreCart (as evidenced from your example).

However, the primary benefit of this hook is to derive the cart item from a given cart item key and return it (so component code doesn't have to have that logic).

useStoreCart can be modified so that it reads the context from something like useCheckoutContext (as per this pull being evaluated) and that determines whether it returns a default preview cart object or not. So in my opinion, useStoreCart should be making the determination on whether requests are made.

To be clear, this isn't a blocking discussion for this pull.

Copy link
Contributor

@nerrad nerrad left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry Rua, the comments here are something I missed in my original review. Hope the suggestions help :)

@@ -22,32 +24,43 @@ import { useStoreCart } from './use-store-cart';
*/
export const useStoreCartItem = ( cartItemKey ) => {
const { cartItems, cartIsLoading } = useStoreCart();
const cartItem = cartItems.filter( ( item ) => item.key === cartItemKey );
const cartItem = cartItems.find( ( item ) => item.key === cartItemKey );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just realized, deriving the cartItem should probably be done in an effect because cartItems might change (incidentally, this is what can cause the issue with the quantity state).

Suggested change
const cartItem = cartItems.find( ( item ) => item.key === cartItemKey );
// note for example I'm just doing `{ quantity: 0, key: '' }` here,
// but this should actually be a default object in the shape of
// a cartItem schema
const [ cartItem, setCartItem ] = useState( { quantity: 0, key: '' } );
useEffect( () => {
if ( ! cartIsLoading && cartItem.key !== cartItemKey ) {
const cartItem = cartItems.find( ( item ) => item.key === cartItemKey );
if ( cartItem ) {
setCartItem( cartItem );
}
}
}, [ cartItems, cartIsLoading, cartItem );

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried to get this working, but the default quantity values in the state cartItem ended up resetting the real cart item quantity to zero on load, and persistence wasn't working at all.

I also tried a variation where the hook only provides quantity state (instead of the full cartItem). It didn't feel right having the full cart object in state, especially since the client component doesn't actually use it. However that had the same issue - the default quantity overwrote the real quantity.

So - @nerrad I'm thinking we merge this PR as is since it's testing and working well. We can still of course iterate on how this hook works, but I'm hesitant to get bogged down in this. Or if you want to jump on the PR and implement this please go ahead :)

Note to be clear the optional chain operator is only needed in the editor. I think what's happening is the preview data (no real/ajax data from hooks) is used, and then in CartLineItemRow the hook fires which requests real cart items. I see this as a bug – we should prevent this from making any real cart AJAX requests in the editor, that's the real cause of the issue IMO.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I accepted the invitation to address this and I think what I've pushed should work. My bad because yes the original suggested code was not quite right.

See if this works well for you and if it does, let's merge.

( select, { dispatch } ) => {
// Store quantity in hook state. This is used to keep the UI
// updated while server request is updated.
const [ quantity, changeQuantity ] = useState( cartItem?.quantity );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya this is because cartItems could change. If you implement my suggestion for deriving cartItem via useEffect then that should take care of this issue.

haszari added 4 commits March 9, 2020 08:43
- work in progress - still need to figure out how to debounce API call
- add new action for updating quantity for an item
- don't set cart item as pending while quantity is updating
  - this leaves QuantitySelector enabled so user can click more/less
- use receiveCartItemQuantity to update quantity in UI before sending request
- use local state for quantity, so ui allows multiple clicks up/down
- debounce store updates (and server/API call)
@haszari haszari force-pushed the fix/debouce-quantity-changes branch from d9de14a to 6da32b5 Compare March 8, 2020 19:43
Comment on lines +68 to +71
if ( debouncedQuantity === 0 ) {
changeQuantity( cartItem.quantity );
return;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This covers initializing things when debouncedQuantity is 0 we want to make sure that the quantities update when we've found a cartItem.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This causes an extra POST request for each cart item on page load, redundantly re-setting the quantity. Are we ok with that?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To close the loop, we definitely did not want an extra POST request. But the issue was that the action (below these lines) was posting even if the quantity was the same as what was in the state. So I fixed this by ensuring the action only pinged the server to update the quantity if needed (by checking the current quantity in the state to make sure it didn't match).

changeQuantity( cartItem.quantity );
return;
}
changeCartItemQuantity( cartItemKey, debouncedQuantity );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You don't need to compare to the quantity here because the effect will only fire when any of the dependencies change (if debouncedQuantity is the same then there's no worry about updating things because the state won't change.

Comment on lines +47 to +56
useEffect( () => {
if ( ! cartIsLoading ) {
const foundCartItem = cartItems.find(
( item ) => item.key === cartItemKey
);
if ( foundCartItem ) {
setCartItem( foundCartItem );
}
}
}, [ cartItems, cartIsLoading, cartItemKey ] );
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to update cartItem every time cartItems change because the quantities will be different in the state. So basically whenever debouncedQuantity triggers an update to the state and cartItems change, then the internal cartItem here will change as well.

quantity: 0,
isPending: false,
changeQuantity: () => void null,
removeItem: () => void null,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How will we keep this cartItem defaults up to date? This is replicated in a few places now. If we iterate on this schema in future, will we need to manually keep this up to date or will we be guided there by bugs or tooling?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could add a typedef for this, then tooling would warn us about JS code that's out of date with the schema (as long as we update the type!).

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this default schema is actually wrong here because it's mixing cartItem with the object returned from the hook StoreCartItem. So I'll need to update this.

With regards to the default shape, a typedef would definitely help here but only if cartItem is exposed outside of this hook. I'll fix this default and consider typedef in a followup.

key: '',
isLoading: true,
cartData: {},
quantity: 0,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This 0 is a special value that we use for forcing an update during load. We should use a const instead of literal, and we could even use a more distinct placeholder value (e.g. -1).

isPending,
quantity,
changeQuantity,
removeItem,
isLoading: cartIsLoading,
cartItem,
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that the client code doesn't actually use cartItem .. perhaps we don't need to return it. Pondering this - this hook is mainly about cart item "metadata" - isPending, quantity, and actions for remove/change quantity. The actual cart item data is accessed via other hooks.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ya I had thought the same thing actually. I'll remove in followup.

@haszari haszari merged commit ff91a98 into master Mar 9, 2020
@haszari haszari deleted the fix/debouce-quantity-changes branch March 9, 2020 02:09
@nerrad nerrad modified the milestones: Future Release, 2.6.0 Apr 17, 2020
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
skip-changelog PRs that you don't want to appear in the changelog.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Quantity changes of cart items is not debounced
4 participants