Commit 81421cf0541 for woocommerce

commit 81421cf054148a254d8849d13741aaf138ee2cd0
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Tue Oct 20 12:04:18 2026 +0800

    Wait for a delayed consent plugin script before analytics tracking (#69980)

    * Wait for a delayed WP Consent API script before analytics tracking

    * Document the wait for a delayed WP Consent API script

    * Add changelog entry for waiting on a delayed WP Consent API script

    * Skip the wait for a delayed consent API script when no consent plugin sets a consent type

    With the WP Consent API active but no consent type, wp_has_consent() grants
    every category, so waiting for its delayed script only dropped visitors who
    never interact. The page now reports whether a consent plugin decides, through
    a consent type or the waitfor_consent_hook filter, and the client waits only then.

    Also cover a delayed script combined with waitfor_consent_hook, and correct the
    consent docblocks.

    * Address review feedback on the delayed consent API wait

diff --git a/packages/php/woocommerce-analytics/README.md b/packages/php/woocommerce-analytics/README.md
index d83c0e94365..1fbe17a6d9f 100644
--- a/packages/php/woocommerce-analytics/README.md
+++ b/packages/php/woocommerce-analytics/README.md
@@ -129,7 +129,9 @@ The package integrates with [WP Consent API](https://wordpress.org/plugins/wp-co

 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.
+If an optimization plugin delays the WP Consent API script, for example until the visitor interacts with the page, nothing is tracked until that script loads and reports statistics consent, and a visit where it never loads is not tracked. This applies when a consent plugin sets a consent type through `wp_get_consent_type` or sets it later in the browser through `wp_consent_api_waitfor_consent_hook`. Both are read into the cacheable page, so they must resolve to the same value for every request. A consent plugin that sets `window.wp_consent_type` only in JavaScript, without either filter, is not detected, and tracking starts before the delayed script loads.
+
+When WP Consent API is not active, the package defaults to allowing all tracking to maintain backward compatibility.

 ## Tracked Events

diff --git a/packages/php/woocommerce-analytics/changelog/fix-analytics-delayed-consent-api b/packages/php/woocommerce-analytics/changelog/fix-analytics-delayed-consent-api
new file mode 100644
index 00000000000..c572c4dff90
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-analytics-delayed-consent-api
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Wait for a consent plugin's delayed script before tracking, instead of tracking visitors who have not consented yet.
diff --git a/packages/php/woocommerce-analytics/src/class-consent-manager.php b/packages/php/woocommerce-analytics/src/class-consent-manager.php
index 17dcf76f368..332c8ffeeb5 100644
--- a/packages/php/woocommerce-analytics/src/class-consent-manager.php
+++ b/packages/php/woocommerce-analytics/src/class-consent-manager.php
@@ -28,6 +28,31 @@ class Consent_Manager {
 		return function_exists( 'wp_has_consent' );
 	}

+	/**
+	 * Check if a consent plugin decides on statistics consent. Without a consent type, the WP Consent API grants every category.
+	 *
+	 * @since 0.18.1
+	 *
+	 * @return bool
+	 */
+	public static function is_consent_managed() {
+		if ( ! self::is_consent_api_active() ) {
+			return false;
+		}
+
+		// Only a stand-in defines wp_has_consent() alone, and it ships no script for the client to wait for.
+		if ( ! function_exists( 'wp_get_consent_type' ) ) {
+			return false;
+		}
+
+		if ( \wp_get_consent_type() ) {
+			return true;
+		}
+
+		// A consent plugin that sets the consent type in the browser, for example from the visitor's region, sets this instead.
+		return (bool) apply_filters( 'wp_consent_api_waitfor_consent_hook', false ); // phpcs:ignore WooCommerce.Commenting.CommentHooks.MissingHookComment -- Reads a WP Consent API filter.
+	}
+
 	/**
 	 * Check if user has consent for analytics tracking
 	 *
diff --git a/packages/php/woocommerce-analytics/src/class-universal.php b/packages/php/woocommerce-analytics/src/class-universal.php
index 7a466211d6a..d71fae6d144 100644
--- a/packages/php/woocommerce-analytics/src/class-universal.php
+++ b/packages/php/woocommerce-analytics/src/class-universal.php
@@ -114,6 +114,9 @@ class Universal {
 					proxy: <?php echo $is_proxy_tracking_enabled ? 'true' : 'false'; ?>,
 				};

+				// A delay-JS plugin can hold back the consent API script, so the client cannot rely on its functions to know it has to wait.
+				wcAnalytics.consentManaged = <?php echo Consent_Manager::is_consent_managed() ? 'true' : 'false'; ?>;
+
 				wcAnalytics.breadcrumbs = <?php echo wp_json_encode( $this->get_breadcrumb_titles(), JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ); ?>;

 				// Page context flags.
diff --git a/packages/php/woocommerce-analytics/src/client/consent.ts b/packages/php/woocommerce-analytics/src/client/consent.ts
index 6d8bcee454f..eb9304a608b 100644
--- a/packages/php/woocommerce-analytics/src/client/consent.ts
+++ b/packages/php/woocommerce-analytics/src/client/consent.ts
@@ -7,6 +7,8 @@ const debug = debugFactory( 'wc-analytics:consent' );

 const WP_CONSENT_API_STATISTICS_TYPE = 'statistics' as const;

+const WP_CONSENT_API_POLL_INTERVAL = 250;
+
 /**
  * Consent utility functions for WP Consent API integration
  */
@@ -15,20 +17,30 @@ export class ConsentManager {
 	private isListenerInitialized = false;

 	/**
-	 * Check if WP Consent API is available
-	 *
-	 * @return The consent status
+	 * Check if the WP Consent API script has loaded, so `wp_has_consent()` can be called.
 	 */
 	isWpConsentApiAvailable(): boolean {
 		return typeof window.wp_has_consent === 'function';
 	}

 	/**
-	 * Check if user has consent for analytics/statistics tracking
+	 * Check if a consent plugin decides on consent but the WP Consent API script, which a delay-JS plugin can hold back, has not loaded.
+	 */
+	isWaitingForConsentApi(): boolean {
+		return ! this.isWpConsentApiAvailable() && window.wcAnalytics?.consentManaged === true;
+	}
+
+	/**
+	 * Check if user has consent for analytics/statistics tracking, which is never the case while waiting for the WP Consent API script.
 	 *
 	 * @return The consent status
 	 */
 	hasAnalyticsConsent(): boolean {
+		if ( this.isWaitingForConsentApi() ) {
+			debug( 'WP Consent API script not loaded yet, no consent until it is' );
+			return false;
+		}
+
 		if ( ! this.isWpConsentApiAvailable() ) {
 			debug( 'WP Consent API not available, defaulting to true for backward compatibility' );
 			return true;
@@ -39,6 +51,27 @@ export class ConsentManager {
 		return hasConsent;
 	}

+	/**
+	 * Run the callback once the WP Consent API script has loaded, or right away when there is no need to wait for it.
+	 *
+	 * @param callback - The callback to run.
+	 */
+	waitForConsentApi( callback: () => void ): void {
+		if ( ! this.isWaitingForConsentApi() ) {
+			callback();
+			return;
+		}
+
+		debug( 'Waiting for the WP Consent API script to load' );
+		// The script dispatches no event when it loads.
+		const timer = window.setInterval( () => {
+			if ( this.isWpConsentApiAvailable() ) {
+				window.clearInterval( timer );
+				callback();
+			}
+		}, WP_CONSENT_API_POLL_INTERVAL );
+	}
+
 	/**
 	 * Run the callback once the consent type is known.
 	 *
@@ -76,7 +109,10 @@ export class ConsentManager {
 	 * Initialize the consent change listener if not already done
 	 */
 	private initializeConsentListener(): void {
-		if ( this.isListenerInitialized || ! this.isWpConsentApiAvailable() ) {
+		if (
+			this.isListenerInitialized ||
+			( ! this.isWpConsentApiAvailable() && ! this.isWaitingForConsentApi() )
+		) {
 			return;
 		}

diff --git a/packages/php/woocommerce-analytics/src/client/index.ts b/packages/php/woocommerce-analytics/src/client/index.ts
index bc54f1201e2..c192a25b208 100644
--- a/packages/php/woocommerce-analytics/src/client/index.ts
+++ b/packages/php/woocommerce-analytics/src/client/index.ts
@@ -27,9 +27,11 @@ jQuery( () => {
 		}
 	} );

-	consentManager.waitForConsentType( () => {
-		if ( consentManager.hasAnalyticsConsent() ) {
-			startTracking();
-		}
-	} );
+	consentManager.waitForConsentApi( () =>
+		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
index 96bf2afe571..ebcbee45d89 100644
--- a/packages/php/woocommerce-analytics/src/client/test/consent.test.ts
+++ b/packages/php/woocommerce-analytics/src/client/test/consent.test.ts
@@ -1,7 +1,7 @@
 /**
  * External dependencies
  */
-import { afterEach, describe, expect, it, jest } from '@jest/globals';
+import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';

 /**
  * Internal dependencies
@@ -59,3 +59,65 @@ describe( 'ConsentManager.waitForConsentType', () => {
 		expect( callback ).toHaveBeenCalledTimes( 1 );
 	} );
 } );
+
+describe( 'ConsentManager with a delayed WP Consent API script', () => {
+	beforeEach( () => {
+		jest.useFakeTimers();
+		window.wcAnalytics = {
+			trackEndpoint: '',
+			eventQueue: [],
+			commonProps: {},
+			features: {},
+			pages: {},
+			assets_url: '',
+			consentManaged: true,
+		};
+	} );
+
+	afterEach( () => {
+		jest.useRealTimers();
+		delete window.wcAnalytics;
+		delete window.wp_has_consent;
+	} );
+
+	it( 'reports no consent until the script loads', () => {
+		expect( new ConsentManager().hasAnalyticsConsent() ).toBe( false );
+	} );
+
+	it( 'reports consent when no consent plugin decides', () => {
+		window.wcAnalytics!.consentManaged = false;
+
+		expect( new ConsentManager().hasAnalyticsConsent() ).toBe( true );
+	} );
+
+	it( 'waits for the script to load', () => {
+		const callback = jest.fn();
+
+		new ConsentManager().waitForConsentApi( callback );
+		jest.runOnlyPendingTimers();
+		expect( callback ).not.toHaveBeenCalled();
+
+		window.wp_has_consent = () => false;
+		jest.runOnlyPendingTimers();
+
+		expect( callback ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'runs right away once the script has loaded', () => {
+		window.wp_has_consent = () => false;
+		const callback = jest.fn();
+
+		new ConsentManager().waitForConsentApi( callback );
+
+		expect( callback ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'runs right away when no consent plugin decides', () => {
+		window.wcAnalytics!.consentManaged = false;
+		const callback = jest.fn();
+
+		new ConsentManager().waitForConsentApi( callback );
+
+		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
index c77a164440a..5b3cc33a8f5 100644
--- a/packages/php/woocommerce-analytics/src/client/test/index.test.ts
+++ b/packages/php/woocommerce-analytics/src/client/test/index.test.ts
@@ -26,9 +26,13 @@ const isTrackingScriptLoaded = () =>
 /**
  * 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.
+ * @param printedTag     - `held` with a consent plugin, `regular` without one.
+ * @param consentManaged - Whether a consent plugin decides, so the client waits for a delayed WP Consent API script.
  */
-async function loadPage( printedTag: 'held' | 'regular' = 'held' ) {
+async function loadPage(
+	printedTag: 'held' | 'regular' = 'held',
+	consentManaged = printedTag === 'held'
+) {
 	const tag = document.createElement( 'script' );
 	tag.setAttribute( 'src', TRACKING_SCRIPT_URL );
 	if ( printedTag === 'held' ) {
@@ -44,6 +48,7 @@ async function loadPage( printedTag: 'held' | 'regular' = 'held' ) {
 		features: {},
 		pages: {},
 		assets_url: '',
+		consentManaged,
 	};
 	// A fresh registry also makes the dynamic import of `../init` run its mock factory again.
 	jest.resetModules();
@@ -51,7 +56,17 @@ async function loadPage( printedTag: 'held' | 'regular' = 'held' ) {
 	await flushPromises();
 }

-const flushPromises = () => new Promise( resolve => setTimeout( resolve, 0 ) );
+const flushPromises = () => jest.advanceTimersByTimeAsync( 0 );
+
+/**
+ * Load the WP Consent API script that a delay-JS plugin held back, and run the client's next check for it.
+ *
+ * @param hasConsent - What `wp_has_consent()` answers.
+ */
+async function loadDelayedConsentApi( hasConsent: boolean | ( () => boolean ) ) {
+	window.wp_has_consent = typeof hasConsent === 'function' ? hasConsent : () => hasConsent;
+	await jest.runOnlyPendingTimersAsync();
+}

 function changeConsent( value: 'allow' | 'deny' ) {
 	document.dispatchEvent(
@@ -66,6 +81,8 @@ describe( 'client entry point', () => {
 		( window as unknown as { jQuery: ( callback: () => void ) => void } ).jQuery = callback =>
 			callback();

+		jest.useFakeTimers();
+
 		// 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(
@@ -81,6 +98,7 @@ describe( 'client entry point', () => {
 			document.removeEventListener( type, listener )
 		);
 		addedListeners.length = 0;
+		jest.useRealTimers();
 		jest.restoreAllMocks();
 		mockInitAnalytics.mockClear();
 		document.head.replaceChildren();
@@ -119,6 +137,78 @@ describe( 'client entry point', () => {
 		expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
 	} );

+	it( 'loads neither s.js nor analytics while a delayed WP Consent API script has not loaded', async () => {
+		await loadPage();
+
+		expect( isTrackingScriptLoaded() ).toBe( false );
+		expect( mockInitAnalytics ).not.toHaveBeenCalled();
+	} );
+
+	it( 'loads s.js and analytics once a delayed WP Consent API script reports consent', async () => {
+		await loadPage();
+
+		await loadDelayedConsentApi( true );
+
+		expect( isTrackingScriptLoaded() ).toBe( true );
+		expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'loads neither s.js nor analytics once a delayed WP Consent API script reports no consent', async () => {
+		await loadPage();
+
+		await loadDelayedConsentApi( false );
+
+		expect( isTrackingScriptLoaded() ).toBe( false );
+		expect( mockInitAnalytics ).not.toHaveBeenCalled();
+	} );
+
+	it( 'loads s.js and analytics once consent is granted after a delayed WP Consent API script loads', async () => {
+		await loadPage();
+		await loadDelayedConsentApi( false );
+
+		changeConsent( 'allow' );
+		await flushPromises();
+
+		expect( isTrackingScriptLoaded() ).toBe( true );
+		expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'loads s.js and analytics once when consent is granted as a delayed WP Consent API script loads', async () => {
+		await loadPage();
+
+		window.wp_has_consent = () => true;
+		changeConsent( 'allow' );
+		await jest.runOnlyPendingTimersAsync();
+
+		expect( getTrackingScripts() ).toHaveLength( 1 );
+		expect( isTrackingScriptLoaded() ).toBe( true );
+		expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'loads s.js and analytics without waiting for a delayed WP Consent API script that no consent plugin uses', async () => {
+		await loadPage( 'held', false );
+
+		expect( isTrackingScriptLoaded() ).toBe( true );
+		expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'waits for the consent type after a delayed WP Consent API script loads', async () => {
+		await loadPage();
+
+		// The script sets the hook from the page, and reports consent until a consent type is set.
+		window.waitfor_consent_hook = '1';
+		await loadDelayedConsentApi( () => window.wp_consent_type === undefined );
+		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( 'leaves a regular s.js tag alone when there is no consent plugin', async () => {
 		await loadPage( 'regular' );

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 e7e08cb421e..d6e69e592d6 100644
--- a/packages/php/woocommerce-analytics/src/client/types/global.d.ts
+++ b/packages/php/woocommerce-analytics/src/client/types/global.d.ts
@@ -12,6 +12,7 @@ declare global {
 			pages: Record< string, boolean >;
 			breadcrumbs?: string[];
 			assets_url: string;
+			consentManaged?: boolean;
 		};
 		_wca?: {
 			push: ( props: Record< string, unknown > ) => void;
diff --git a/packages/php/woocommerce-analytics/tests/php/Universal_Page_Output_Test.php b/packages/php/woocommerce-analytics/tests/php/Universal_Page_Output_Test.php
index 5e9278ea455..c911b3cd75c 100644
--- a/packages/php/woocommerce-analytics/tests/php/Universal_Page_Output_Test.php
+++ b/packages/php/woocommerce-analytics/tests/php/Universal_Page_Output_Test.php
@@ -324,6 +324,63 @@ class Universal_Page_Output_Test extends BaseTestCase {
 		$this->assertStringNotContainsString( 'window.wcAnalytics ||', $output );
 	}

+	/**
+	 * Test that the page output tells the client no consent plugin decides without the WP Consent API.
+	 */
+	public function test_page_output_reports_consent_unmanaged_without_the_consent_api(): void {
+		$this->assertStringContainsString( 'wcAnalytics.consentManaged = false;', $this->render_analytics_data() );
+	}
+
+	/**
+	 * Test that the page output tells the client no consent plugin decides when none sets a consent type.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_page_output_reports_consent_unmanaged_without_a_consent_type(): void {
+		require_once __DIR__ . '/mocks/wp-consent-api-functions.php';
+
+		$this->assertStringContainsString( 'wcAnalytics.consentManaged = false;', $this->render_analytics_data() );
+	}
+
+	/**
+	 * Test that the page output tells the client no consent plugin decides when only wp_has_consent() is defined.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_page_output_reports_consent_unmanaged_with_only_wp_has_consent(): void {
+		require_once __DIR__ . '/mocks/wp-has-consent-function.php';
+
+		$this->assertStringContainsString( 'wcAnalytics.consentManaged = false;', $this->render_analytics_data() );
+	}
+
+	/**
+	 * Test that the page output tells the client a consent plugin decides when it sets a consent type.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_page_output_reports_consent_managed_with_a_consent_type(): void {
+		require_once __DIR__ . '/mocks/wp-consent-api-functions.php';
+		add_filter( 'wp_get_consent_type', fn() => 'optin' );
+
+		$this->assertStringContainsString( 'wcAnalytics.consentManaged = true;', $this->render_analytics_data() );
+	}
+
+	/**
+	 * Test that the page output tells the client a consent plugin decides when it sets the consent type in the browser.
+	 *
+	 * @runInSeparateProcess
+	 * @preserveGlobalState disabled
+	 */
+	public function test_page_output_reports_consent_managed_when_the_consent_type_is_set_later(): void {
+		require_once __DIR__ . '/mocks/wp-consent-api-functions.php';
+		add_filter( 'wp_consent_api_waitfor_consent_hook', '__return_true' );
+
+		$this->assertStringContainsString( 'wcAnalytics.consentManaged = true;', $this->render_analytics_data() );
+	}
+
 	/**
 	 * Test that the server-fired pixel path keeps its request-derived
 	 * properties. That path runs on uncached requests and is the only place
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
index 9f82ad776fd..5f8ec73295e 100644
--- 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
@@ -5,14 +5,15 @@
  * @package automattic/woocommerce-analytics
  */

-if ( ! function_exists( 'wp_has_consent' ) ) {
+require_once __DIR__ . '/wp-has-consent-function.php';
+
+if ( ! function_exists( 'wp_get_consent_type' ) ) {
 	/**
-	 * Mock of the WP Consent API check.
+	 * Mock of the WP Consent API consent type, which consent plugins set through the same filter.
 	 *
-	 * @param string $category Consent category.
-	 * @return bool
+	 * @return string
 	 */
-	function wp_has_consent( $category ) { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
-		return false;
+	function wp_get_consent_type() { // phpcs:ignore Universal.Files.SeparateFunctionsFromOO.Mixed
+		return apply_filters( 'wp_get_consent_type', '' );
 	}
 }
diff --git a/packages/php/woocommerce-analytics/tests/php/mocks/wp-has-consent-function.php b/packages/php/woocommerce-analytics/tests/php/mocks/wp-has-consent-function.php
new file mode 100644
index 00000000000..d35ba7ec829
--- /dev/null
+++ b/packages/php/woocommerce-analytics/tests/php/mocks/wp-has-consent-function.php
@@ -0,0 +1,18 @@
+<?php
+/**
+ * Stand-in for a plugin that defines only the WP Consent API check. Load it only in a separate process.
+ *
+ * @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;
+	}
+}