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;
+ }
+}