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