Skip to content

Commit 062aeb3

Browse files
duracelltomiclaude
andcommitted
Ship the single-product data in a span so the block add-to-cart stays interactive
WooCommerce's blockified Add to Cart + Options block buffers the output of the classic add-to-cart hooks and renders a legacy full-page POST form (no Interactivity API submit handler) as soon as that buffer contains any INPUT, TEXTAREA, SELECT, BUTTON or FORM tag. The hidden <input name="gtm4wp_product_data"> printed on woocommerce_after_add_to_cart_button is exactly such a tag, so with e-commerce tracking enabled every block-theme product page lost its interactive add to cart and did a full page reload (GitHub #462, verified against the WC 10.9.1 source). The product data now travels in a hidden <span class="gtm4wp_single_productdata" data-gtm4wp_product_data="..."> instead, which the has_form_elements() scan ignores. The class deliberately differs from the .gtm4wp_productdata list markup, which the tracker sweeps page-wide into view_item_list impressions a product detail page must not join. Both JS readers (the simple-product click path and the found_variation handler) select the span first and keep reading the old input as a fallback, so a cached page rendered by an older plugin version keeps tracking through the upgrade. Nothing server-side ever read the posted input value, so dropping the form field loses nothing. Regression tests pin both directions: the span markup is asserted present and form elements asserted absent on the PHP side, and the tracker suite drives the span-only markup for both the add_to_cart click and the variation view_item. The has_form_elements() coupling is registered as upstream row U114. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a4ff3a2 commit 062aeb3

7 files changed

Lines changed: 214 additions & 11 deletions

File tree

.upstream/upstream-review-checklist.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ have none, which is exactly why they are `every-run`.
265265
| U30 | Cross-sell block selectors `.wp-block-woocommerce-cart-cross-sells-block`, `.wc-block-cart-cross-sells-product` | `js/frontend/gtm4wp-woocommerce-blocks.js` | WC Blocks markup | U77 | silent-missing | on-WC-release | [x] 2026-08-06 (S1a) D12 fixed — prefix corrected and pinned by a regression test that fails on the old value |
266266
| U97 | **The cart-fragments delivery channel** (cache-safe data layer only). Three claims, all silent if wrong: (a) the `woocommerce_add_to_cart_fragments` filter still exists and its selector⇒HTML contract is unchanged — applied in `WC_AJAX::get_refreshed_fragments()`, reached by the `get_refreshed_fragments`, `add_to_cart` and `remove_from_cart` endpoints; (b) WC still re-applies fragments from its own `sessionStorage` cache on a normal page load, which is what delivers the block when no cart mutation happened; (c) **the handle `wc-cart-fragments` is still registered under that name** — we now `wp_enqueue_script()` it ourselves, unguarded, and WP drops an unregistered queued handle with no notice. Covered by the L-1 support policy that `WC_Frontend_Scripts`' own docblock grants third-party enqueueing. **Do not "fix" (c) by adding a `wp_script_is( …, 'registered' )` guard** — our callback and WC's `load_scripts()` share `wp_enqueue_scripts` priority 10, so the guard would be a race that silently skips the enqueue; queue resolution happens at print time | `src/Modules/WooCommerce/PageDataLayer.php` (`add_visitor_cart_fragment`, `output_visitor_cart_placeholder`), `src/Modules/WooCommerce/WooCommerceModule.php` (`enqueue_scripts`), `js/frontend/gtm4wp-visitor-data.js` | WC `includes/class-wc-frontend-scripts.php`, `includes/widgets/class-wc-widget-cart.php`, `includes/class-wc-ajax.php`, `client/legacy/js/frontend/cart-fragments.js` | U77, U70 | silent-missing | on-WC-release | [x] 2026-08-06 read on `trunk` (11.1.0-dev); `cart-fragments.js` byte-identical on tags `11.0.0` and `10.4.0`. **Measured, not assumed:** the handle is registered unconditionally by `load_scripts()` (sole gate `did_action('before_woocommerce_init')`) but WC enqueues it from exactly **one** frontend path, `WC_Widget_Cart::widget()`, which returns early when `woocommerce_widget_cart_is_hidden` is true — **default `is_cart() \|\| is_checkout()`** — and the Mini-Cart *block* never enqueues it (it uses a parallel Store API layer on `localStorage.storeApiCartHash`). That is why (c) was failing in the field. `wc_load_cart_fragments()`, `woocommerce_enqueue_cart_fragments` and `woocommerce_cart_fragments_refresh` **do not exist** — do not look for them. Cost claim behind the changelog wording: the refresh XHR fires once per *tab* (its cache is `sessionStorage`), and on **every** page load when Web Storage is blocked (`cart-fragments.js` has no guard for that) |
267267
| U99 | **Legacy product grid block container classes → GA4 list identity.** The 8 `wp-block-{block_name}` classes `AbstractProductGrid::get_container_classes()` emits, each mapped to a hardcoded `item_list_name` **and** `item_list_id`, plus the `.wc-block-grid` / `.wc-block-grid__products` wrapper shape the lookup walks. WC's `woocommerce_blocks_product_grid_item_html` carries no block context, so PHP writes a generic placeholder pair and the browser resolves the real identity from the container class. The 8 ids are the same literals `list_identity()` gives those lists on the Product Collection path (U26) — one list, two files, and they must not drift (UC-6) | `js/frontend/gtm4wp-woocommerce.js` (`gtm4wp_product_block_names` + its three `gtm4wp_update_json_in_node` calls); placeholder pair from `src/Modules/WooCommerce/ListTracking.php` | `AbstractProductGrid::get_container_classes()` + each subclass's `$block_name`, via `gh api repos/woocommerce/woocommerce/contents/…` | U77 — **soft-deprecated since WC 9.5: every legacy grid `block.json` carries `"supports": { "inserter": false }` (confirmed at 11.0.0), the blocks stay registered and server-rendered for existing content, and the successor is `woocommerce/product-collection`. Removal, not renaming, is the expected end state; watch WC deprecation notes and the subclass count, and do NOT drop our support — widget areas and saved content still render these** | silent-wrong | on-WC-release | [x] 2026-08-06 measured on the **11.0.0 tag**: `search/code repo:woocommerce/woocommerce "extends AbstractProductGrid"` → `total_count: 8` (`ProductNew, ProductOnSale, ProductTopRated, ProductCategory, ProductTag, HandpickedProducts, ProductBestSellers, ProductsByAttribute`), map keys = `wp-block-` + each `$block_name`, so the map is exactly complete: 8 keys vs 8 subclasses. `get_container_classes()` verified to put `wc-block-grid` and `wp-block-{name}` on the same element. Pinned by the 8-row table in `js/frontend/test/woocommerce-tracker.test.js` |
268+
| U114 | **`AddToCartWithOptions::has_form_elements()` legacy-mode scan** (blockified Add to Cart + Options block, WC 10.0+). Two claims, both silent if wrong: (a) the block buffers the output of the classic `woocommerce_before/after_add_to_cart_*` hooks and renders a classic POST form (no `data-wp-on--submit`, interactive add to cart off) when a buffer contains an `INPUT`, `TEXTAREA`, `SELECT`, `BUTTON` or `FORM` tag — so the single-product tracking markup must stay a non-form element (hidden `span`, #462); (b) a `SPAN` does not trip the scan. If the tag list widens, the interactive add to cart dies again on block themes with no error and a green suite | `src/Modules/WooCommerce/ListTracking.php` `single_add_to_cart_tracking()` | `plugins/woocommerce/src/Blocks/BlockTypes/AddToCartWithOptions/AddToCartWithOptions.php` (`has_form_elements()`, `$legacy_mode`), via `gh api repos/woocommerce/woocommerce/contents/…` | U77 | silent-wrong | on-WC-release | [x] 2026-08-13 read at tag 10.9.1: tag list `INPUT, TEXTAREA, SELECT, BUTTON, FORM`; `$hooks_after` buffers `woocommerce_after_add_to_cart_button`; legacy mode omits `data-wp-on--submit`. Our side pinned by `ListTrackingTest::test_single_add_to_cart_tracking_output_contains_no_form_elements()` |
268269

269270
| U113 | **The visitor checks WooCommerce's `order_received()` applies once it has resolved an order**, which return before `woocommerce_thankyou` and are therefore invisible to this plugin. Read so the data layer includes `orderData.customer`, `new_customer`/`customer_type` and the purchase event's `user_data` exactly when WooCommerce itself renders the order — parity in **both** directions, because wherever upstream renders, the page body is already showing this visitor the order, so withholding more than upstream deletes tracking data without hiding anything. Five couplings, measured at release tags (not trunk): **(a) `WC_Shortcode_Checkout::guest_should_verify_email( WC_Order, string ): bool`, `private static` — the feature-detect symbol.** Both gates and this member shipped together in **7.9.0** (absent at 7.8.0, present 7.9.0→11.0.0); `method_exists()` sees private members and triggers `WC_Autoloader` (`wc_shortcode_` → `shortcodes/`, mapped since ≤5.0.0), so the probe needs no load-order luck. Absent ⇒ a 5.0–7.8.x WooCommerce that renders the order to any valid key holder ⇒ nothing to mirror, **nothing withheld, no version compared anywhere**. **(b)** the known-shopper login gate: **behaviour 7.9.0 (unconditional); its filter `woocommerce_order_received_verify_known_shoppers` (default `true`, 1 arg) only 8.4.0** — one upstream line, two `@since` values; with the filter off upstream still routes a non-owner into the guest email verification, whose only customer-id term is the logged-in-owner short-circuit. **(c)** `Automattic\WooCommerce\Internal\Utilities\Users::should_user_verify_order_email( $order_id, $supplied_email = null, $context = 'view' )` — `public static`, order **id** not object; **the METHOD is 8.6.0+ while the CLASS exists from ≥7.7.0 — re-verify with `method_exists`; `class_exists` alone reports the wrong half**. `Internal` namespace, no compatibility promise (UC-2), hence the guard. **(d)** `woocommerce_order_email_verification_grace_period` — **`@since` 8.0.0, 3 args**, homed in the shortcode 8.0.0–8.5.x and in the helper from 8.6.0; upstream compares elapsed `<=` grace (mirrored exactly); **7.9.x has no grace at all** — the one window where the mirror is laxer than upstream, bounded by the order max-age gate. **(e)** `woocommerce_order_email_verification_required` — **`@since` 7.9.0, 3 args**, upstream's final say and documented opt-out, applied only past the grace short-circuit (both facts mirrored). On 7.9.0–8.5.x (helper absent) the fallback mirrors (b)–(e) term by term for **every order shape reaching it**; the three request-identity terms — session email match, the POSTed-email escape hatch (`check_submission` / nonce `wc_verify_email`), `read_private_shop_orders` — are deliberately not modelled and each fails **closed** | `src/Modules/WooCommerce/PageDataLayer.php` `woocommerce_hides_order_from_visitor()` | `includes/shortcodes/class-wc-shortcode-checkout.php` `order_received()` + `guest_should_verify_email()`; `src/Internal/Utilities/Users.php` | U77 | silent-wrong | on-WC-release | [x] 2026-08-12 read at tags 5.0.0 / 7.8.0 / 7.9.0 / 8.0.0 / 8.4.0 / 8.5.2 / 8.6.0 / 11.0.0; an executable old/new/upstream decision table swept every version window × order shape × filter state with zero unexplained divergences |
270271

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ Major rewrite of the plugin - please read the announcement post on gtm4wp.com be
119119
* Fixed: when "Exclude tax from revenue" is enabled, per-item prices on the `purchase` event are now also reported excluding tax. Previously the transaction `value` was tax-exclusive while the item prices followed the shop's display setting (often tax-inclusive), so GA4 item-level revenue (product performance) did not reconcile with the transaction total (sales performance).
120120
* Fixed: variable **subscription** products now keep their variant data in ecommerce tracking. Variations were detected by an exact `variation` product-type match, but WooCommerce Subscriptions variations report `subscription_variation`, so their `item_variant`, `item_group_id` and parent-derived `item_category`/`item_brand` were dropped (most visibly on the `purchase` event). Variations are now detected structurally (any `WC_Product_Variation`), covering subscriptions and similar extensions.
121121
* Fixed: the dynamic-remarketing "Product ID prefix" is now kept on variations. When a variation was selected on a variable product page, the browser swapped in the variation id and dropped the configured prefix from the `id` field used for Google/Meta catalog matching; the prefix is now re-applied to the variation's `id` (the unprefixed `item_id` is unchanged).
122+
* Fixed: adding a product to the cart no longer forces a full page reload on stores using WooCommerce's blockified **Add to Cart + Options** block (block themes, WooCommerce 10.9+). With e-commerce tracking enabled, the plugin printed a hidden input field into the add-to-cart form, and WooCommerce deliberately renders a classic full-page POST form instead of the interactive one when a plugin prints form fields into it - so the interactive add to cart was disabled on every product page while GTM4WP was active. The product data now travels in a hidden span's data attribute (the same pattern product lists already use), which WooCommerce's check ignores. Tracking itself is unchanged: classic (non-block) product templates keep working exactly as before, and the tracker still reads the old hidden input as a fallback so cached pages keep tracking through the upgrade. As reported on GitHub (#462).
122123
* Fixed: the `[add_to_cart]` shortcode button now fires an `add_to_cart` event. A standalone shortcode button is rendered outside a product loop, so it never received the hidden product-data markup that product-list items get; the GA4 item data is now attached to the button itself so a click can be tracked. Product lists are unaffected — they already carry the data.
123124
* Fixed: the product-page `add_to_cart` event is no longer fired when the browser blocks the add-to-cart form submit because a required field is empty (e.g. a required Product Add-ons field). The click now respects the form's HTML5 validity, so a rejected add no longer produces a false `add_to_cart`.
124125
* Fixed: `add_to_cart` and `remove_from_cart` now always report `quantity` as a number, and report it the same way on every surface. Three symptoms of one cause: a product form with no quantity field at all - some themes and product add-on plugins render none - emitted `quantity: null` and `value: 0`; the cart page reported a string where the mini-cart reported a number for the very same product; and a cart line set to zero fired a removal event on the cart page while the mini-cart correctly suppressed it. Every quantity now goes through one parser, so the type and the zero handling are identical everywhere. **Check your GTM setup if a trigger or variable compares `quantity` against a string.**

js/frontend/gtm4wp-woocommerce.js

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -355,18 +355,26 @@ function gtm4wp_track_single_add_to_cart( trigger_element, product_form ) {
355355
value: sum_value.toFixed( 2 ),
356356
} );
357357
} else {
358+
// The hidden span with the data attribute is the current markup: an input
359+
// inside the form would flip WooCommerce's blockified add-to-cart form
360+
// into its legacy full-page POST mode (#462). The [name=gtm4wp_product_data]
361+
// input is still read as a fallback so a cached page rendered by an older
362+
// plugin version keeps tracking.
358363
const product_data_el = form.querySelector(
359-
'[name=gtm4wp_product_data]'
364+
'.gtm4wp_single_productdata,[name=gtm4wp_product_data]'
360365
);
361366
if ( ! product_data_el ) {
362367
return false;
363368
}
364369

365370
// Keep internal_id from being excluded so #405 can look up the stored list
366371
// by product id; it is deleted again before the push below.
367-
const productdata = gtm4wp_read_from_json( product_data_el.value, [
368-
'productlink',
369-
] );
372+
const productdata = gtm4wp_read_from_json(
373+
( product_data_el.dataset &&
374+
product_data_el.dataset.gtm4wp_product_data ) ||
375+
product_data_el.value,
376+
[ 'productlink' ]
377+
);
370378
// #69: the previous guard read `isNaN( quantity )` AFTER a lookup that
371379
// short-circuits to null, and isNaN( null ) is false - so a product form
372380
// with no quantity field emitted quantity: null and value: 0.
@@ -925,8 +933,9 @@ function gtm4wp_woocommerce_process_pages() {
925933
return true;
926934
}
927935

936+
// Same span-first, input-fallback pair as the simple product path (#462).
928937
const product_data_el = product_form.querySelector(
929-
'[name=gtm4wp_product_data]'
938+
'.gtm4wp_single_productdata,[name=gtm4wp_product_data]'
930939
);
931940
if ( ! product_data_el ) {
932941
return true;
@@ -935,7 +944,9 @@ function gtm4wp_woocommerce_process_pages() {
935944
let current_product_detail_data;
936945
try {
937946
current_product_detail_data = JSON.parse(
938-
product_data_el.value
947+
( product_data_el.dataset &&
948+
product_data_el.dataset.gtm4wp_product_data ) ||
949+
product_data_el.value
939950
);
940951
} catch ( e ) {
941952
console && console.error && console.error( e.message );

js/frontend/test/woocommerce-tracker.test.js

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,145 @@ describe( 'gtm4wp-woocommerce variation id prefix (#383)', () => {
410410
} );
411411
} );
412412

413+
describe( 'gtm4wp-woocommerce PDP span markup (#462)', () => {
414+
// The PDP product data ships in a hidden span, never in a form element:
415+
// WooCommerce's blockified Add to Cart + Options block scans the add-to-cart
416+
// hook output and renders a legacy full-page POST form when it finds an
417+
// input, which disabled the interactive add to cart on block themes. These
418+
// cases boot the tracker against the span-only markup; the input-based
419+
// fixtures elsewhere in this file stay valid on purpose, because the tracker
420+
// still reads [name=gtm4wp_product_data] as a cached-page fallback.
421+
let handlers;
422+
423+
beforeEach( () => {
424+
document.body.className = '';
425+
426+
global.gtm4wp_datalayer_name = 'dataLayer';
427+
global.gtm4wp_currency = 'EUR';
428+
global.gtm4wp_product_per_impression = 0;
429+
global.gtm4wp_clear_ecommerce = false;
430+
global.gtm4wp_console_log = false;
431+
global.gtm4wp_use_sku_instead = false;
432+
global.gtm4wp_remarketing_prod_id_prefix = '';
433+
global.gtm4wp_make_sure_is_float = ( v ) => parseFloat( v ) || 0;
434+
window.dataLayer = [];
435+
window.gtm4wp_datalayer_max_timeout = 0;
436+
window.google_tag_manager = { 'GTM-TEST': {} };
437+
438+
global.gtm4wp_push_ecommerce = jest.fn();
439+
global.gtm4wp_read_from_json = ( json ) => {
440+
const parsed = JSON.parse( json );
441+
delete parsed.productlink;
442+
delete parsed.internal_id;
443+
return parsed;
444+
};
445+
global.gtm4wp_read_json_from_node = ( el, key, exclude = [] ) => {
446+
const raw = el && el.dataset && el.dataset[ key ];
447+
if ( ! raw ) {
448+
return false;
449+
}
450+
const parsed = JSON.parse( raw );
451+
exclude.forEach( ( k ) => delete parsed[ k ] );
452+
return parsed;
453+
};
454+
455+
handlers = {};
456+
const jq = {
457+
on: ( ...args ) => {
458+
const evt = args[ 0 ];
459+
const fn = args[ args.length - 1 ];
460+
if ( typeof fn === 'function' ) {
461+
handlers[ evt ] = fn;
462+
}
463+
return jq;
464+
},
465+
trigger: () => jq,
466+
ajaxSuccess: () => jq,
467+
};
468+
global.jQuery = jest.fn( () => jq );
469+
470+
jest.useFakeTimers();
471+
} );
472+
473+
afterEach( () => {
474+
jest.useRealTimers();
475+
delete window.google_tag_manager;
476+
delete window.gtm4wp_datalayer_max_timeout;
477+
} );
478+
479+
const boot = () => {
480+
jest.isolateModules( () => require( '../gtm4wp-woocommerce' ) );
481+
jest.runAllTimers();
482+
global.gtm4wp_push_ecommerce.mockClear();
483+
};
484+
485+
it( 'fires add_to_cart from the span with no plugin input in the form', () => {
486+
document.body.innerHTML =
487+
'<form class="cart" method="post" action="https://shop/p42">' +
488+
'<span class="gtm4wp_single_productdata" style="display:none"></span>' +
489+
'<input type="number" name="quantity" value="3" />' +
490+
'<button type="button" class="single_add_to_cart_button">Add</button>' +
491+
'</form>';
492+
document
493+
.querySelector( '.gtm4wp_single_productdata' )
494+
.setAttribute(
495+
'data-gtm4wp_product_data',
496+
JSON.stringify( PRODUCT_DATA )
497+
);
498+
499+
boot();
500+
document
501+
.querySelector( '.single_add_to_cart_button' )
502+
.dispatchEvent(
503+
new window.MouseEvent( 'click', { bubbles: true } )
504+
);
505+
506+
const call = global.gtm4wp_push_ecommerce.mock.calls.find(
507+
( c ) => c[ 0 ] === 'add_to_cart'
508+
);
509+
expect( call ).toBeDefined();
510+
expect( call[ 1 ][ 0 ] ).toEqual(
511+
expect.objectContaining( { item_id: 42 } )
512+
);
513+
expect( call[ 1 ][ 0 ].quantity ).toBe( 3 );
514+
} );
515+
516+
it( 'reads the span in the found_variation handler', () => {
517+
document.body.innerHTML =
518+
'<form class="cart variations_form">' +
519+
'<span class="gtm4wp_single_productdata" style="display:none"></span>' +
520+
'</form>';
521+
document.querySelector( '.gtm4wp_single_productdata' ).setAttribute(
522+
'data-gtm4wp_product_data',
523+
JSON.stringify( {
524+
item_id: 10,
525+
id: 10,
526+
item_name: 'Parent',
527+
price: 5,
528+
internal_id: 10,
529+
} )
530+
);
531+
532+
boot();
533+
handlers.found_variation(
534+
{ target: document.querySelector( 'form' ) },
535+
{
536+
variation_id: 456,
537+
sku: 'VAR-SKU',
538+
display_price: 9.99,
539+
attributes: { attribute_pa_color: 'blue' },
540+
}
541+
);
542+
543+
const call = global.gtm4wp_push_ecommerce.mock.calls.find(
544+
( c ) => c[ 0 ] === 'view_item'
545+
);
546+
expect( call ).toBeDefined();
547+
expect( call[ 1 ][ 0 ].item_id ).toBe( 456 );
548+
expect( call[ 1 ][ 0 ].item_group_id ).toBe( 10 );
549+
} );
550+
} );
551+
413552
describe( 'gtm4wp-woocommerce exposed add_to_cart trackers (#273)', () => {
414553
const PRODUCT = { item_id: 77, item_name: 'API Product', price: 12 };
415554

0 commit comments

Comments
 (0)