Commit 3bc9f7a1070 for woocommerce
commit 3bc9f7a107055987d60fbc227d4033b6eb2cfe05
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date: Mon Oct 19 10:51:55 2026 +0800
Wait for statistics consent before running analytics s.js (#69748)
* Add Jest unit test setup to the analytics package
* Load analytics s.js only after statistics consent with a consent plugin
* Add changelog entry for loading s.js after statistics consent
* Keep the analytics script handle registered when a consent plugin is active
* Clarify analytics consent comments and cover a late allowed consent type
* Document when the analytics package loads s.js with a consent plugin
* Print analytics s.js inert and run it after statistics consent
* Only run a held analytics script tag that points at s.js
* Start analytics tracking when consent comes before the consent type
* Log why a held analytics s.js tag is not run
* Derive the held analytics s.js tag id from its script handle
* Tighten analytics s.js consent tests for attribute order, nonce and API check
* Reword the analytics s.js consent changelog entry
* Fix analytics isolated PHP tests failing on PHP 7.4
diff --git a/packages/php/woocommerce-analytics/README.md b/packages/php/woocommerce-analytics/README.md
index bb6b24cceae..d83c0e94365 100644
--- a/packages/php/woocommerce-analytics/README.md
+++ b/packages/php/woocommerce-analytics/README.md
@@ -127,6 +127,8 @@ not from the filter, and the option is written there.
The package integrates with [WP Consent API](https://wordpress.org/plugins/wp-consent-api/) to ensure GDPR and privacy regulation compliance. Tracking only occurs when users have granted consent for statistics collection.
+With WP Consent API active, `stats.wp.com/s-*.js`, which sets the `tk_*` referrer cookies, prints as `type="text/plain"` and only runs after statistics consent. Dequeuing or filtering its `woocommerce-analytics` script handle works as before.
+
When WP Consent API is not available, the package defaults to allowing all tracking to maintain backward compatibility.
## Tracked Events
diff --git a/packages/php/woocommerce-analytics/babel.config.js b/packages/php/woocommerce-analytics/babel.config.js
new file mode 100644
index 00000000000..1658d09852a
--- /dev/null
+++ b/packages/php/woocommerce-analytics/babel.config.js
@@ -0,0 +1,10 @@
+// Only Jest uses this. Webpack passes its own babel-loader options, so the build gets an empty config here.
+module.exports = api =>
+ api.env( 'test' )
+ ? {
+ presets: [
+ [ '@babel/preset-env', { targets: { node: 'current' } } ],
+ '@babel/preset-typescript',
+ ],
+ }
+ : {};
diff --git a/packages/php/woocommerce-analytics/changelog/fix-analytics-sjs-consent b/packages/php/woocommerce-analytics/changelog/fix-analytics-sjs-consent
new file mode 100644
index 00000000000..fb7bbca9c16
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-analytics-sjs-consent
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Hold the analytics tracking script until visitors consent to statistics when a consent plugin is active.
diff --git a/packages/php/woocommerce-analytics/jest.config.js b/packages/php/woocommerce-analytics/jest.config.js
new file mode 100644
index 00000000000..86858ecdef0
--- /dev/null
+++ b/packages/php/woocommerce-analytics/jest.config.js
@@ -0,0 +1,4 @@
+module.exports = {
+ testEnvironment: 'jsdom',
+ roots: [ '<rootDir>/src/client' ],
+};
diff --git a/packages/php/woocommerce-analytics/package.json b/packages/php/woocommerce-analytics/package.json
index 7a4e79cb68e..f54fa388a51 100644
--- a/packages/php/woocommerce-analytics/package.json
+++ b/packages/php/woocommerce-analytics/package.json
@@ -42,6 +42,7 @@
"@wordpress/dependency-extraction-webpack-plugin": "next",
"babel-loader": "9.2.x",
"jest": "29.5.x",
+ "jest-environment-jsdom": "29.5.x",
"typescript": "5.7.x",
"webpack": "catalog:tooling",
"webpack-cli": "5.1.x"
@@ -63,6 +64,20 @@
"push"
]
},
+ {
+ "name": "JavaScript",
+ "command": "test",
+ "changes": [
+ "babel.config.js",
+ "jest.config.js",
+ "package.json",
+ "src/client/**/*.ts"
+ ],
+ "events": [
+ "pull_request",
+ "push"
+ ]
+ },
{
"name": "JS build and typecheck",
"command": "build:ci",
diff --git a/packages/php/woocommerce-analytics/src/class-consent-manager.php b/packages/php/woocommerce-analytics/src/class-consent-manager.php
index 2eff5f4d2cb..17dcf76f368 100644
--- a/packages/php/woocommerce-analytics/src/class-consent-manager.php
+++ b/packages/php/woocommerce-analytics/src/class-consent-manager.php
@@ -17,13 +17,24 @@ class Consent_Manager {
*/
const WP_CONSENT_API_STATISTICS_TYPE = 'statistics';
+ /**
+ * Check if the WP Consent API is active, meaning a consent plugin may decide on statistics consent.
+ *
+ * @since 0.18.1
+ *
+ * @return bool
+ */
+ public static function is_consent_api_active() {
+ return function_exists( 'wp_has_consent' );
+ }
+
/**
* Check if user has consent for analytics tracking
*
* @return bool
*/
public static function has_analytics_consent() {
- if ( ! function_exists( 'wp_has_consent' ) ) {
+ if ( ! self::is_consent_api_active() ) {
// If WP Consent API is not available, default to true for backward compatibility
return true;
}
diff --git a/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php b/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php
index 931b325794b..6663b25cd5f 100644
--- a/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php
+++ b/packages/php/woocommerce-analytics/src/class-woocommerce-analytics.php
@@ -10,6 +10,7 @@ namespace Automattic;
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Connection\Manager as Jetpack_Connection;
+use Automattic\Woocommerce_Analytics\Consent_Manager;
use Automattic\Woocommerce_Analytics\My_Account;
use Automattic\Woocommerce_Analytics\Universal;
use Automattic\Woocommerce_Analytics\WC_Analytics_Tracking_Proxy;
@@ -64,6 +65,13 @@ class Woocommerce_Analytics {
*/
const PROXY_TRACKING_EVER_ENABLED_OPTION = 'woocommerce_analytics_proxy_tracking_ever_enabled';
+ /**
+ * Script handle for s.js. WordPress prints its tag with the id `{handle}-js`.
+ *
+ * @var string
+ */
+ private const TRACKING_SCRIPT_HANDLE = 'woocommerce-analytics';
+
/**
* Initializer.
* Used to configure the WooCommerce Analytics package.
@@ -173,8 +181,13 @@ class Woocommerce_Analytics {
gmdate( 'YW' )
);
+ // s.js sets referrer cookies as soon as it runs, so with a consent plugin it prints inert for the client to run after consent.
+ if ( Consent_Manager::is_consent_api_active() ) {
+ add_filter( 'wp_script_attributes', array( __CLASS__, 'hold_tracking_script_until_consent' ) );
+ }
+
wp_enqueue_script(
- 'woocommerce-analytics',
+ self::TRACKING_SCRIPT_HANDLE,
$url,
array(),
null, // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- The version is set in the URL.
@@ -185,6 +198,25 @@ class Woocommerce_Analytics {
);
}
+ /**
+ * Print the s.js tag as `type="text/plain"` so it does not run until the client activates it after statistics consent.
+ *
+ * @since 0.18.1
+ *
+ * @param mixed $attributes The script tag attributes.
+ * @return mixed
+ */
+ public static function hold_tracking_script_until_consent( $attributes ) {
+ if ( ! is_array( $attributes ) || ( $attributes['id'] ?? '' ) !== self::TRACKING_SCRIPT_HANDLE . '-js' ) {
+ return $attributes;
+ }
+
+ $attributes['type'] = 'text/plain';
+ $attributes['data-wc-analytics-consent'] = Consent_Manager::WP_CONSENT_API_STATISTICS_TYPE;
+
+ return $attributes;
+ }
+
/**
* Enqueue client-side analytics script.
*/
diff --git a/packages/php/woocommerce-analytics/src/client/consent.ts b/packages/php/woocommerce-analytics/src/client/consent.ts
index 56fe8e8b080..6d8bcee454f 100644
--- a/packages/php/woocommerce-analytics/src/client/consent.ts
+++ b/packages/php/woocommerce-analytics/src/client/consent.ts
@@ -39,6 +39,29 @@ export class ConsentManager {
return hasConsent;
}
+ /**
+ * Run the callback once the consent type is known.
+ *
+ * A consent plugin that sets the consent type late, for example from the visitor's region, sets
+ * `waitfor_consent_hook`. Until it dispatches `wp_consent_type_defined`, `wp_has_consent()` answers from the
+ * fallback consent type, which reports consent when it is empty.
+ *
+ * @param callback - The callback to run.
+ */
+ waitForConsentType( callback: () => void ): void {
+ if (
+ ! this.isWpConsentApiAvailable() ||
+ ! window.waitfor_consent_hook ||
+ typeof window.wp_consent_type !== 'undefined'
+ ) {
+ callback();
+ return;
+ }
+
+ debug( 'Waiting for the consent type to be defined' );
+ document.addEventListener( 'wp_consent_type_defined', callback, { once: true } );
+ }
+
/**
* Add a listener for consent changes
*
diff --git a/packages/php/woocommerce-analytics/src/client/index.ts b/packages/php/woocommerce-analytics/src/client/index.ts
index f58cdb89103..bc54f1201e2 100644
--- a/packages/php/woocommerce-analytics/src/client/index.ts
+++ b/packages/php/woocommerce-analytics/src/client/index.ts
@@ -5,22 +5,31 @@
*/
import './public-path';
import { consentManager } from './consent';
+import { activateTrackingScript } from './tracking-script';
+
+/**
+ * Start tracking. Safe to call again, and s.js sends the events analytics queues in `window._wca` whenever it arrives.
+ */
+function startTracking() {
+ activateTrackingScript();
+ import( './init' );
+}
jQuery( () => {
if ( ! window.wcAnalytics ) {
return;
}
- // Check for consent before initializing analytics
- if ( consentManager.hasAnalyticsConsent() ) {
- import( './init' );
- return;
- }
-
- // Set up consent change listener to initialize when consent is granted
+ // Consent given on the page counts without the consent type, so this does not wait for it.
consentManager.addConsentChangeListener( ( hasConsent: boolean ) => {
if ( hasConsent ) {
- import( './init' );
+ startTracking();
+ }
+ } );
+
+ consentManager.waitForConsentType( () => {
+ if ( consentManager.hasAnalyticsConsent() ) {
+ startTracking();
}
} );
} );
diff --git a/packages/php/woocommerce-analytics/src/client/test/consent.test.ts b/packages/php/woocommerce-analytics/src/client/test/consent.test.ts
new file mode 100644
index 00000000000..96bf2afe571
--- /dev/null
+++ b/packages/php/woocommerce-analytics/src/client/test/consent.test.ts
@@ -0,0 +1,61 @@
+/**
+ * External dependencies
+ */
+import { afterEach, describe, expect, it, jest } from '@jest/globals';
+
+/**
+ * Internal dependencies
+ */
+import { ConsentManager } from '../consent';
+
+describe( 'ConsentManager.waitForConsentType', () => {
+ afterEach( () => {
+ delete window.wp_has_consent;
+ delete window.waitfor_consent_hook;
+ delete window.wp_consent_type;
+ } );
+
+ it( 'runs right away without the WP Consent API', () => {
+ window.waitfor_consent_hook = '1';
+ const callback = jest.fn();
+
+ new ConsentManager().waitForConsentType( callback );
+
+ expect( callback ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'runs right away when the consent plugin does not ask to wait', () => {
+ window.wp_has_consent = () => false;
+ const callback = jest.fn();
+
+ new ConsentManager().waitForConsentType( callback );
+
+ expect( callback ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'runs right away when the consent type is already defined', () => {
+ window.wp_has_consent = () => false;
+ window.waitfor_consent_hook = '1';
+ window.wp_consent_type = 'optin';
+ const callback = jest.fn();
+
+ new ConsentManager().waitForConsentType( callback );
+
+ expect( callback ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'waits for the consent plugin to define the consent type', () => {
+ window.wp_has_consent = () => false;
+ window.waitfor_consent_hook = '1';
+ const callback = jest.fn();
+
+ new ConsentManager().waitForConsentType( callback );
+ expect( callback ).not.toHaveBeenCalled();
+
+ window.wp_consent_type = 'optin';
+ document.dispatchEvent( new CustomEvent( 'wp_consent_type_defined' ) );
+ document.dispatchEvent( new CustomEvent( 'wp_consent_type_defined' ) );
+
+ expect( callback ).toHaveBeenCalledTimes( 1 );
+ } );
+} );
diff --git a/packages/php/woocommerce-analytics/src/client/test/index.test.ts b/packages/php/woocommerce-analytics/src/client/test/index.test.ts
new file mode 100644
index 00000000000..c77a164440a
--- /dev/null
+++ b/packages/php/woocommerce-analytics/src/client/test/index.test.ts
@@ -0,0 +1,173 @@
+/**
+ * External dependencies
+ */
+import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
+
+const TRACKING_SCRIPT_URL = 'https://stats.wp.com/s-202642.js';
+
+jest.mock( '../public-path', () => ( {} ) );
+
+const mockInitAnalytics = jest.fn();
+jest.mock( '../init', () => {
+ mockInitAnalytics();
+ return {};
+} );
+
+const addedListeners: Array< [ string, EventListenerOrEventListenerObject ] > = [];
+
+const getTrackingScripts = () =>
+ Array.from( document.querySelectorAll< HTMLScriptElement >( 'script' ) ).filter(
+ script => script.getAttribute( 'src' ) === TRACKING_SCRIPT_URL
+ );
+
+const isTrackingScriptLoaded = () =>
+ getTrackingScripts().some( script => script.getAttribute( 'type' ) !== 'text/plain' );
+
+/**
+ * Print the s.js tag the way PHP does, then run the entry point as a fresh page load and let its dynamic import settle.
+ *
+ * @param printedTag - `held` with a consent plugin, `regular` without one.
+ */
+async function loadPage( printedTag: 'held' | 'regular' = 'held' ) {
+ const tag = document.createElement( 'script' );
+ tag.setAttribute( 'src', TRACKING_SCRIPT_URL );
+ if ( printedTag === 'held' ) {
+ tag.setAttribute( 'type', 'text/plain' );
+ tag.setAttribute( 'data-wc-analytics-consent', 'statistics' );
+ }
+ document.head.appendChild( tag );
+
+ window.wcAnalytics = {
+ trackEndpoint: '',
+ eventQueue: [],
+ commonProps: {},
+ features: {},
+ pages: {},
+ assets_url: '',
+ };
+ // A fresh registry also makes the dynamic import of `../init` run its mock factory again.
+ jest.resetModules();
+ require( '../index' );
+ await flushPromises();
+}
+
+const flushPromises = () => new Promise( resolve => setTimeout( resolve, 0 ) );
+
+function changeConsent( value: 'allow' | 'deny' ) {
+ document.dispatchEvent(
+ new CustomEvent( 'wp_listen_for_consent_change', {
+ detail: { statistics: value },
+ } )
+ );
+}
+
+describe( 'client entry point', () => {
+ beforeEach( () => {
+ ( window as unknown as { jQuery: ( callback: () => void ) => void } ).jQuery = callback =>
+ callback();
+
+ // Track listeners so one test's page cannot react to events dispatched in the next.
+ const addEventListener = document.addEventListener.bind( document );
+ jest.spyOn( document, 'addEventListener' ).mockImplementation(
+ ( type: string, listener, options ) => {
+ addedListeners.push( [ type, listener ] );
+ addEventListener( type, listener, options );
+ }
+ );
+ } );
+
+ afterEach( () => {
+ addedListeners.forEach( ( [ type, listener ] ) =>
+ document.removeEventListener( type, listener )
+ );
+ addedListeners.length = 0;
+ jest.restoreAllMocks();
+ mockInitAnalytics.mockClear();
+ document.head.replaceChildren();
+ delete window.wcAnalytics;
+ delete window.wp_has_consent;
+ delete window.waitfor_consent_hook;
+ delete window.wp_consent_type;
+ } );
+
+ it( 'loads s.js and analytics when statistics consent is given', async () => {
+ window.wp_has_consent = () => true;
+
+ await loadPage();
+
+ expect( isTrackingScriptLoaded() ).toBe( true );
+ expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'loads neither s.js nor analytics without statistics consent', async () => {
+ window.wp_has_consent = () => false;
+
+ await loadPage();
+
+ expect( isTrackingScriptLoaded() ).toBe( false );
+ expect( mockInitAnalytics ).not.toHaveBeenCalled();
+ } );
+
+ it( 'loads s.js and analytics once consent is granted on the page', async () => {
+ window.wp_has_consent = () => false;
+ await loadPage();
+
+ changeConsent( 'allow' );
+ await flushPromises();
+
+ expect( isTrackingScriptLoaded() ).toBe( true );
+ expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'leaves a regular s.js tag alone when there is no consent plugin', async () => {
+ await loadPage( 'regular' );
+
+ expect( getTrackingScripts() ).toHaveLength( 1 );
+ expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'waits for the consent type before checking consent', async () => {
+ // WP Consent API reports consent until a consent type is set.
+ window.wp_has_consent = () => window.wp_consent_type === undefined;
+ window.waitfor_consent_hook = '1';
+
+ await loadPage();
+ expect( isTrackingScriptLoaded() ).toBe( false );
+ expect( mockInitAnalytics ).not.toHaveBeenCalled();
+
+ window.wp_consent_type = 'optin';
+ document.dispatchEvent( new CustomEvent( 'wp_consent_type_defined' ) );
+ await flushPromises();
+
+ expect( isTrackingScriptLoaded() ).toBe( false );
+ expect( mockInitAnalytics ).not.toHaveBeenCalled();
+ } );
+
+ it( 'loads s.js and analytics when consent is granted before the consent type is defined', async () => {
+ window.wp_has_consent = () => false;
+ window.waitfor_consent_hook = '1';
+ await loadPage();
+
+ changeConsent( 'allow' );
+ await flushPromises();
+
+ expect( isTrackingScriptLoaded() ).toBe( true );
+ expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'loads s.js and analytics once a late consent type allows statistics', async () => {
+ window.wp_has_consent = () => true;
+ window.waitfor_consent_hook = '1';
+
+ await loadPage();
+ expect( isTrackingScriptLoaded() ).toBe( false );
+ expect( mockInitAnalytics ).not.toHaveBeenCalled();
+
+ window.wp_consent_type = 'optout';
+ document.dispatchEvent( new CustomEvent( 'wp_consent_type_defined' ) );
+ await flushPromises();
+
+ expect( isTrackingScriptLoaded() ).toBe( true );
+ expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
+ } );
+} );
diff --git a/packages/php/woocommerce-analytics/src/client/test/tracking-script.test.ts b/packages/php/woocommerce-analytics/src/client/test/tracking-script.test.ts
new file mode 100644
index 00000000000..4decb76feb1
--- /dev/null
+++ b/packages/php/woocommerce-analytics/src/client/test/tracking-script.test.ts
@@ -0,0 +1,88 @@
+/**
+ * External dependencies
+ */
+import { afterEach, describe, expect, it } from '@jest/globals';
+
+/**
+ * Internal dependencies
+ */
+import { activateTrackingScript } from '../tracking-script';
+
+const TRACKING_SCRIPT_URL = 'https://stats.wp.com/s-202642.js';
+
+function printHeldTag( attributes: Record< string, string > = { src: TRACKING_SCRIPT_URL } ) {
+ const held = document.createElement( 'script' );
+ held.setAttribute( 'type', 'text/plain' );
+ held.setAttribute( 'id', 'woocommerce-analytics-js' );
+ held.setAttribute( 'data-wc-analytics-consent', 'statistics' );
+ for ( const [ name, value ] of Object.entries( attributes ) ) {
+ held.setAttribute( name, value );
+ }
+ document.head.appendChild( held );
+}
+
+const getScripts = () => Array.from( document.querySelectorAll( 'script' ) );
+
+describe( 'activateTrackingScript', () => {
+ afterEach( () => {
+ document.head.replaceChildren();
+ } );
+
+ it( 'replaces the held s.js tag with one that runs, keeping its attributes', () => {
+ printHeldTag( { src: TRACKING_SCRIPT_URL, crossorigin: 'anonymous' } );
+
+ activateTrackingScript();
+
+ const scripts = getScripts();
+ expect( scripts ).toHaveLength( 1 );
+ expect( scripts[ 0 ].getAttribute( 'type' ) ).toBeNull();
+ expect( scripts[ 0 ].hasAttribute( 'data-wc-analytics-consent' ) ).toBe( false );
+ expect( scripts[ 0 ].src ).toBe( TRACKING_SCRIPT_URL );
+ expect( scripts[ 0 ].id ).toBe( 'woocommerce-analytics-js' );
+ expect( scripts[ 0 ].getAttribute( 'crossorigin' ) ).toBe( 'anonymous' );
+ } );
+
+ it( 'keeps the nonce of the held tag', () => {
+ printHeldTag();
+ // Browsers hide the nonce attribute once the tag is parsed, leaving it only on the property.
+ Object.defineProperty( document.querySelector( 'script' )!, 'nonce', { value: 'abc123' } );
+
+ activateTrackingScript();
+
+ expect( getScripts()[ 0 ].nonce ).toBe( 'abc123' );
+ } );
+
+ it( 'adds s.js only once', () => {
+ printHeldTag();
+
+ activateTrackingScript();
+ activateTrackingScript();
+
+ expect( getScripts() ).toHaveLength( 1 );
+ } );
+
+ it( 'leaves a held tag alone once a consent plugin has moved its src', () => {
+ printHeldTag( { 'data-cmplz-src': TRACKING_SCRIPT_URL } );
+
+ activateTrackingScript();
+
+ const scripts = getScripts();
+ expect( scripts ).toHaveLength( 1 );
+ expect( scripts[ 0 ].getAttribute( 'type' ) ).toBe( 'text/plain' );
+ } );
+
+ it.each( [
+ 'https://evil.example/s-202642.js',
+ 'https://stats.wp.com/other.js',
+ 'http://stats.wp.com/s-202642.js',
+ 'javascript:alert(1)',
+ ] )( 'never runs a held tag whose src is not s.js: %s', src => {
+ printHeldTag( { src } );
+
+ activateTrackingScript();
+
+ const scripts = getScripts();
+ expect( scripts ).toHaveLength( 1 );
+ expect( scripts[ 0 ].getAttribute( 'type' ) ).toBe( 'text/plain' );
+ } );
+} );
diff --git a/packages/php/woocommerce-analytics/src/client/tracking-script.ts b/packages/php/woocommerce-analytics/src/client/tracking-script.ts
new file mode 100644
index 00000000000..a43a8021d10
--- /dev/null
+++ b/packages/php/woocommerce-analytics/src/client/tracking-script.ts
@@ -0,0 +1,61 @@
+/**
+ * External dependencies
+ */
+import debugFactory from 'debug';
+
+const debug = debugFactory( 'wc-analytics:tracking-script' );
+
+const HELD_ATTRIBUTE = 'data-wc-analytics-consent';
+
+/**
+ * Check that a URL is the weekly s.js, so markup injected into page content cannot use this to run another script.
+ *
+ * @param src - The held tag's src.
+ */
+function isTrackingScriptUrl( src: string ): boolean {
+ try {
+ const url = new URL( src, document.baseURI );
+ return (
+ url.protocol === 'https:' &&
+ url.hostname === 'stats.wp.com' &&
+ /^\/s-\d+\.js$/.test( url.pathname )
+ );
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Run s.js, which PHP printed as `type="text/plain"` because it sets referrer cookies as soon as it runs.
+ *
+ * Replacing the held tag keeps its attributes (and nonce), and makes a second call a no-op.
+ */
+export function activateTrackingScript(): void {
+ const held = document.querySelector< HTMLScriptElement >(
+ `script[type="text/plain"][${ HELD_ATTRIBUTE }]`
+ );
+
+ if ( ! held ) {
+ return;
+ }
+
+ const src = held.getAttribute( 'src' );
+ if ( ! src ) {
+ debug( 'A consent plugin took over the held s.js tag and will run it' );
+ return;
+ }
+
+ if ( ! isTrackingScriptUrl( src ) ) {
+ debug( 'Not running a held tag whose src is not s.js:', src );
+ return;
+ }
+
+ const script = document.createElement( 'script' );
+ for ( const { name, value } of Array.from( held.attributes ) ) {
+ if ( name !== 'type' && name !== HELD_ATTRIBUTE ) {
+ script.setAttribute( name, value );
+ }
+ }
+ script.nonce = held.nonce;
+ held.replaceWith( script );
+}
diff --git a/packages/php/woocommerce-analytics/src/client/types/global.d.ts b/packages/php/woocommerce-analytics/src/client/types/global.d.ts
index 453ee3b84f0..e7e08cb421e 100644
--- a/packages/php/woocommerce-analytics/src/client/types/global.d.ts
+++ b/packages/php/woocommerce-analytics/src/client/types/global.d.ts
@@ -17,6 +17,8 @@ declare global {
push: ( props: Record< string, unknown > ) => void;
};
wp_has_consent?: ( type: string ) => boolean;
+ waitfor_consent_hook?: string;
+ wp_consent_type?: string;
}
}
diff --git a/packages/php/woocommerce-analytics/tests/php/Tracking_Script_Consent_Test.php b/packages/php/woocommerce-analytics/tests/php/Tracking_Script_Consent_Test.php
new file mode 100644
index 00000000000..d87cdfad3e4
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/Tracking_Script_Consent_Test.php
@@ -0,0 +1,105 @@
+<?php
+/**
+ * Tests for how s.js is printed around the WP Consent API.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+namespace Automattic\Woocommerce_Analytics;
+
+use Automattic\Woocommerce_Analytics;
+use WorDBless\BaseTestCase;
+
+/**
+ * Tests that s.js prints inert, for the client to run after statistics consent, whenever the WP Consent API is active.
+ */
+class Tracking_Script_Consent_Test extends BaseTestCase {
+
+ /**
+ * Remove the scripts and the filter so each test starts without them.
+ */
+ public function tear_down(): void {
+ remove_filter( 'wp_script_attributes', array( Woocommerce_Analytics::class, 'hold_tracking_script_until_consent' ) );
+ foreach ( array( 'woocommerce-analytics', 'analytics-dependent' ) as $handle ) {
+ wp_dequeue_script( $handle );
+ wp_deregister_script( $handle );
+ }
+
+ parent::tear_down();
+ }
+
+ /**
+ * Test that without the WP Consent API, s.js prints as a regular script, as before.
+ */
+ public function test_tracking_script_runs_without_the_consent_api(): void {
+ Woocommerce_Analytics::enqueue_tracking_script();
+
+ $tracking = $this->get_script_attributes( $this->print_scripts(), 'woocommerce-analytics-js' );
+
+ $this->assertIsArray( $tracking );
+ $this->assertMatchesRegularExpression( '#^https://stats\.wp\.com/s-\d+\.js$#', $tracking['src'] ?? '' );
+ $this->assertNotSame( 'text/plain', $tracking['type'] ?? null );
+ $this->assertArrayNotHasKey( 'data-wc-analytics-consent', $tracking );
+ }
+
+ /**
+ * Test that with the WP Consent API, s.js prints inert and other scripts are untouched.
+ *
+ * @runInSeparateProcess
+ * @preserveGlobalState disabled
+ */
+ public function test_tracking_script_prints_inert_when_the_consent_api_is_active(): void {
+ require_once __DIR__ . '/mocks/wp-consent-api-functions.php';
+
+ Woocommerce_Analytics::enqueue_tracking_script();
+ wp_enqueue_script( 'analytics-dependent', 'https://example.com/dependent.js', array( 'woocommerce-analytics' ), '1.0' );
+
+ $output = $this->print_scripts();
+ $tracking = $this->get_script_attributes( $output, 'woocommerce-analytics-js' );
+ $dependent = $this->get_script_attributes( $output, 'analytics-dependent-js' );
+
+ $this->assertIsArray( $tracking );
+ $this->assertSame( 'text/plain', $tracking['type'] ?? null );
+ $this->assertSame( 'statistics', $tracking['data-wc-analytics-consent'] ?? null );
+ $this->assertMatchesRegularExpression( '#^https://stats\.wp\.com/s-\d+\.js$#', $tracking['src'] ?? '' );
+
+ $this->assertIsArray( $dependent );
+ $this->assertSame( 'https://example.com/dependent.js?ver=1.0', $dependent['src'] ?? null );
+ $this->assertNotSame( 'text/plain', $dependent['type'] ?? null );
+ }
+
+ /**
+ * Print the enqueued scripts and return the markup.
+ *
+ * @return string
+ */
+ private function print_scripts(): string {
+ ob_start();
+ wp_print_scripts();
+ return (string) ob_get_clean();
+ }
+
+ /**
+ * Read the attributes of the script tag with the given id. WordPress 7.0+ prints them in a different order than 6.9.
+ *
+ * @param string $output Printed markup.
+ * @param string $id Script tag id.
+ * @return array<string, string|true>|null Null when there is no such tag.
+ */
+ private function get_script_attributes( string $output, string $id ): ?array {
+ $processor = new \WP_HTML_Tag_Processor( $output );
+ while ( $processor->next_tag( 'script' ) ) {
+ if ( $processor->get_attribute( 'id' ) !== $id ) {
+ continue;
+ }
+
+ $attributes = array();
+ foreach ( $processor->get_attribute_names_with_prefix( '' ) as $name ) {
+ $attributes[ $name ] = $processor->get_attribute( $name );
+ }
+ return $attributes;
+ }
+
+ return null;
+ }
+}
diff --git a/packages/php/woocommerce-analytics/tests/php/bootstrap.php b/packages/php/woocommerce-analytics/tests/php/bootstrap.php
index 7e525d9d918..89f7bbfee7e 100644
--- a/packages/php/woocommerce-analytics/tests/php/bootstrap.php
+++ b/packages/php/woocommerce-analytics/tests/php/bootstrap.php
@@ -15,6 +15,11 @@ require_once __DIR__ . '/mocks/woocommerce-functions.php';
require_once __DIR__ . '/mocks/class-wc-order.php';
require_once __DIR__ . '/mocks/class-wc-tracks.php';
+// A @runInSeparateProcess test runs from stdin with an empty SCRIPT_FILENAME, so wp_guess_url() warns on PHP 7.4.
+if ( empty( $_SERVER['SCRIPT_FILENAME'] ) ) {
+ $_SERVER['SCRIPT_FILENAME'] = __FILE__;
+}
+
// Initialize WordPress test environment using WorDBless (database-less).
\WorDBless\Load::load();
diff --git a/packages/php/woocommerce-analytics/tests/php/mocks/wp-consent-api-functions.php b/packages/php/woocommerce-analytics/tests/php/mocks/wp-consent-api-functions.php
new file mode 100644
index 00000000000..9f82ad776fd
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/mocks/wp-consent-api-functions.php
@@ -0,0 +1,18 @@
+<?php
+/**
+ * Stand-in for the WP Consent API plugin. Load it only in a separate process: a function cannot be undefined.
+ *
+ * @package automattic/woocommerce-analytics
+ */
+
+if ( ! function_exists( 'wp_has_consent' ) ) {
+ /**
+ * Mock of the WP Consent API check.
+ *
+ * @param string $category Consent category.
+ * @return bool
+ */
+ function wp_has_consent( $category ) { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
+ return false;
+ }
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 86609db6a86..044c138d119 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2504,6 +2504,9 @@ importers:
jest:
specifier: 29.5.x
version: 29.5.0(@types/node@24.12.2)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3))
+ jest-environment-jsdom:
+ specifier: 29.5.x
+ version: 29.5.0
typescript:
specifier: 5.7.x
version: 5.7.3