Skip to content

Commit a072614

Browse files
Merge pull request #4554 from woocommerce/dev/PCP-6639-v2-insonsistent-pay-pal-button-behavior-for-subscriptions-on-cart
Insonsistent PayPal button behavior for subscriptions on cart (6639 v2)
2 parents 0fbf899 + 72507f9 commit a072614

5 files changed

Lines changed: 243 additions & 2 deletions

File tree

modules/ppcp-blocks/resources/js/checkout-block.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
paypalSubscriptionButtonAllowed,
99
} from './Helper/Subscription';
1010
import { loadPayPalScript } from '../../../ppcp-button/resources/js/modules/Helper/PayPalScriptLoading';
11+
import { initCartFragmentSync } from '../../../ppcp-button/resources/js/modules/Helper/CartFragmentSync';
1112
import BlockCheckoutMessagesBootstrap from './Bootstrap/BlockCheckoutMessagesBootstrap';
1213
import { PayPalComponent } from './Components/paypal';
1314
import { BlockEditorPayPalComponent } from './Components/block-editor-paypal';
@@ -19,17 +20,30 @@ const config = wc.wcSettings.getSetting( 'ppcp-gateway_data' );
1920

2021
window.ppcpFundingSource = config.fundingSource;
2122

23+
// Keep the classic header mini-cart count in sync with the block cart/checkout,
24+
// which mutate the Store API cart instead of firing WooCommerce's jQuery cart
25+
// fragment events. Idempotent with the same call in the button bundle.
26+
initCartFragmentSync();
27+
2228
let paypalScriptPromise = null;
2329

24-
const features = [ 'products' ];
30+
// Mirror the gateway's server-side (mode-aware) `supports` so WooCommerce Blocks
31+
// does not filter the PayPal method out when the cart requires a feature the
32+
// gateway actually supports — notably `multiple_subscriptions` when the cart holds
33+
// two or more subscriptions. Falls back to the previous hard-coded list.
34+
const features = Array.isArray( config.supportedFeatures )
35+
? [ ...config.supportedFeatures ]
36+
: [ 'products' ];
2537
let blockEnabled = true;
2638

2739
if ( cartHasSubscriptionProducts( config.scriptData ) ) {
2840
// Show the button only for subscription carts PayPal can process
2941
// (shared rule used by the classic cart and mini-cart as well).
3042
blockEnabled = paypalSubscriptionButtonAllowed( config.scriptData );
3143

32-
features.push( 'subscriptions' );
44+
if ( ! Array.isArray( config.supportedFeatures ) ) {
45+
features.push( 'subscriptions' );
46+
}
3347
}
3448

3549
if ( blockEnabled ) {

modules/ppcp-blocks/src/PayPalPaymentMethod.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,10 @@ public function get_payment_method_data() {
259259
'placeOrderButtonText' => $this->place_order_button_text,
260260
'placeOrderButtonDescription' => $this->place_order_button_description,
261261
'enabledFundingSources' => $funding_sources,
262+
// The gateway's (mode-aware) supported features, so the block can
263+
// declare them to WooCommerce Blocks and not be filtered out when the
264+
// cart requires one (e.g. `multiple_subscriptions` for 2+ subscriptions).
265+
'supportedFeatures' => array_values( (array) $this->gateway->supports ),
262266
'ajax' => array(
263267
'update_shipping' => array(
264268
'endpoint' => WC_AJAX::get_endpoint( UpdateShippingEndpoint::ENDPOINT ),

modules/ppcp-button/resources/js/button.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ import { loadPaypalScript } from './modules/Helper/ScriptLoading';
2424
import buttonModuleWatcher from './modules/ButtonModuleWatcher';
2525
import MessagesBootstrap from './modules/ContextBootstrap/MessagesBootstrap';
2626
import { apmButtonsInit } from './modules/Helper/ApmButtons';
27+
import { initCartFragmentSync } from './modules/Helper/CartFragmentSync';
28+
29+
// Keep the classic header mini-cart count in sync with the block cart. Runs at
30+
// module scope (not gated by bootstrap or DOMContentLoaded) so it works in every
31+
// context regardless of button gating or script timing.
32+
initCartFragmentSync();
2733

2834
// TODO: could be a good idea to have a separate spinner for each gateway,
2935
// but I think we care mainly about the script loading, so one spinner should be enough.
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import { debounce } from '@ppcp-blocks/Helper/debounce';
2+
3+
/**
4+
* Reads the current cart signature (item count + total) from the block Store
5+
* API data store, or null when the store is not available.
6+
*
7+
* @param {Object} cart The `wc/store/cart` selector.
8+
* @return {string|null} A stable signature string, or null.
9+
*/
10+
const readCartSignature = ( cart ) => {
11+
if ( ! cart ) {
12+
return null;
13+
}
14+
15+
const cartData = cart.getCartData?.() ?? null;
16+
const itemsCount = cartData?.itemsCount ?? cart.getItemsCount?.() ?? null;
17+
if ( itemsCount === null ) {
18+
return null;
19+
}
20+
21+
// Include the total so coupon/total-only changes also refresh the widget.
22+
const total = cartData?.totals?.total_price ?? '';
23+
24+
return `${ itemsCount }:${ total }`;
25+
};
26+
27+
/**
28+
* Keeps the classic header mini-cart (`.cart-contents`) count in sync with the
29+
* block Cart/Checkout.
30+
*
31+
* The block cart mutates the `wc/store/cart` data store instead of firing the
32+
* jQuery events (`wc_fragment_refresh`, `added_to_cart`, ...) that WooCommerce's
33+
* `wc-cart-fragments` script listens to. On classic-theme headers this leaves
34+
* the mini-cart count stale after items are added/removed in the block cart.
35+
* Here we watch the store and trigger a fragment refresh when the cart changes.
36+
*
37+
* Idempotent: several entry points call this (the button bundle and the block
38+
* integration bundle) so it works regardless of which one runs on a given page;
39+
* only the first call subscribes. It is a no-op on pages without the Store API.
40+
*
41+
* @return {void}
42+
*/
43+
export const initCartFragmentSync = () => {
44+
if ( typeof wp === 'undefined' || ! wp.data?.subscribe ) {
45+
return;
46+
}
47+
48+
// Only subscribe once, even when called from multiple bundles.
49+
if ( window.ppcpCartFragmentSyncActive ) {
50+
return;
51+
}
52+
window.ppcpCartFragmentSyncActive = true;
53+
54+
// Coalesce bursts of store updates into a single fragment refresh.
55+
const refreshFragments = debounce( () => {
56+
if ( typeof jQuery !== 'undefined' ) {
57+
jQuery( document.body ).trigger( 'wc_fragment_refresh' );
58+
}
59+
}, 300 );
60+
61+
// The `wc/store/cart` store may not be registered yet at load time (block
62+
// scripts load asynchronously), so subscribe unconditionally and seed the
63+
// signature lazily on the first notification where the store is ready.
64+
let seeded = false;
65+
let lastSignature = null;
66+
67+
wp.data.subscribe( () => {
68+
const signature = readCartSignature(
69+
wp.data.select?.( 'wc/store/cart' )
70+
);
71+
if ( signature === null ) {
72+
return;
73+
}
74+
75+
// Seed on the first available read so page load does not refresh.
76+
if ( ! seeded ) {
77+
seeded = true;
78+
lastSignature = signature;
79+
return;
80+
}
81+
82+
if ( signature === lastSignature ) {
83+
return;
84+
}
85+
lastSignature = signature;
86+
87+
refreshFragments();
88+
} );
89+
};
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/* global describe, test, expect, beforeEach, afterEach, jest */
2+
import { initCartFragmentSync } from './CartFragmentSync';
3+
4+
describe( 'initCartFragmentSync', () => {
5+
let subscribedCallback;
6+
let cartData;
7+
let triggerSpy;
8+
let storeAvailable;
9+
10+
const makeCart = () => ( {
11+
getCartData: () => cartData,
12+
getItemsCount: () => cartData?.itemsCount ?? null,
13+
} );
14+
15+
const setup = () => {
16+
subscribedCallback = null;
17+
storeAvailable = true;
18+
19+
global.wp = {
20+
data: {
21+
select: jest.fn( ( store ) =>
22+
store === 'wc/store/cart' && storeAvailable
23+
? makeCart()
24+
: null
25+
),
26+
subscribe: jest.fn( ( cb ) => {
27+
subscribedCallback = cb;
28+
} ),
29+
},
30+
};
31+
32+
triggerSpy = jest.fn();
33+
global.jQuery = jest.fn( () => ( { trigger: triggerSpy } ) );
34+
};
35+
36+
beforeEach( () => {
37+
jest.useFakeTimers();
38+
delete window.ppcpCartFragmentSyncActive;
39+
cartData = { itemsCount: 2, totals: { total_price: '2000' } };
40+
setup();
41+
} );
42+
43+
afterEach( () => {
44+
jest.useRealTimers();
45+
delete global.wp;
46+
delete global.jQuery;
47+
delete window.ppcpCartFragmentSyncActive;
48+
} );
49+
50+
// Fires one store notification and flushes the debounced refresh.
51+
const fireStoreUpdate = () => {
52+
subscribedCallback();
53+
jest.advanceTimersByTime( 300 );
54+
};
55+
56+
// The first notification only seeds the baseline; call this before asserting
57+
// on subsequent changes.
58+
const seed = () => fireStoreUpdate();
59+
60+
test( 'is a no-op when wp is undefined', () => {
61+
delete global.wp;
62+
expect( () => initCartFragmentSync() ).not.toThrow();
63+
} );
64+
65+
test( 'never refreshes while the cart store is unavailable', () => {
66+
storeAvailable = false;
67+
initCartFragmentSync();
68+
69+
// Subscription is registered even though the store is not ready yet.
70+
expect( global.wp.data.subscribe ).toHaveBeenCalled();
71+
72+
fireStoreUpdate();
73+
fireStoreUpdate();
74+
expect( triggerSpy ).not.toHaveBeenCalled();
75+
} );
76+
77+
test( 'does not refresh on the initial (seeding) notification', () => {
78+
initCartFragmentSync();
79+
seed();
80+
expect( triggerSpy ).not.toHaveBeenCalled();
81+
} );
82+
83+
test( 'triggers wc_fragment_refresh when the item count changes', () => {
84+
initCartFragmentSync();
85+
seed();
86+
87+
cartData = { itemsCount: 0, totals: { total_price: '0' } };
88+
fireStoreUpdate();
89+
90+
expect( triggerSpy ).toHaveBeenCalledTimes( 1 );
91+
expect( triggerSpy ).toHaveBeenCalledWith( 'wc_fragment_refresh' );
92+
} );
93+
94+
test( 'triggers on total-only changes (e.g. coupon) without count change', () => {
95+
initCartFragmentSync();
96+
seed();
97+
98+
cartData = { itemsCount: 2, totals: { total_price: '1500' } };
99+
fireStoreUpdate();
100+
101+
expect( triggerSpy ).toHaveBeenCalledTimes( 1 );
102+
} );
103+
104+
test( 'does not trigger when the cart signature is unchanged', () => {
105+
initCartFragmentSync();
106+
seed();
107+
fireStoreUpdate();
108+
expect( triggerSpy ).not.toHaveBeenCalled();
109+
} );
110+
111+
test( 'seeds lazily on the first read after the store becomes available', () => {
112+
storeAvailable = false;
113+
initCartFragmentSync();
114+
115+
// Notifications before the store is ready neither seed nor refresh.
116+
fireStoreUpdate();
117+
118+
// Store becomes available: first ready read seeds, no refresh.
119+
storeAvailable = true;
120+
fireStoreUpdate();
121+
expect( triggerSpy ).not.toHaveBeenCalled();
122+
123+
// A subsequent change now refreshes.
124+
cartData = { itemsCount: 0, totals: { total_price: '0' } };
125+
fireStoreUpdate();
126+
expect( triggerSpy ).toHaveBeenCalledTimes( 1 );
127+
} );
128+
} );

0 commit comments

Comments
 (0)