Commit b2593a4c3ef for woocommerce

commit b2593a4c3ef37de99bdbb36112c535afc127a659
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Wed Oct 21 11:26:35 2026 +0800

    Clear Tracks cookies when shoppers withdraw statistics consent (#69981)

    * Clear Tracks cookies when analytics statistics consent is withdrawn

    * Document clearing Tracks cookies on statistics consent withdrawal

    * Add changelog entry for clearing Tracks cookies on consent withdrawal

    * Cover the host-only Tracks cookie expiry in analytics tests

    * Restore the analytics visitor cookie when statistics consent is granted again

    * Drop queued analytics events as soon as statistics consent is withdrawn

    * Stop s.js from tracking when consent is withdrawn while it loads

    * Cover clearing Tracks cookies after a delayed consent script denies statistics

    * Restore the analytics entry point test file's original formatting

    * Drop a failed analytics batch when statistics consent was withdrawn while it was sent

    * Restore the analytics visitor cookie on HTTP stores after statistics consent is granted again

diff --git a/packages/php/woocommerce-analytics/README.md b/packages/php/woocommerce-analytics/README.md
index 1fbe17a6d9f..c97aa58fb6a 100644
--- a/packages/php/woocommerce-analytics/README.md
+++ b/packages/php/woocommerce-analytics/README.md
@@ -129,6 +129,8 @@ 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.

+Withdrawing statistics consent expires the `tk_*` cookies on the page and drops events not yet sent. A page loaded with statistics consent denied expires any `tk_*` cookies left from an earlier visit.
+
 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.
diff --git a/packages/php/woocommerce-analytics/changelog/fix-analytics-clear-tracks-cookies-on-consent-withdrawal b/packages/php/woocommerce-analytics/changelog/fix-analytics-clear-tracks-cookies-on-consent-withdrawal
new file mode 100644
index 00000000000..6654f4d1d0f
--- /dev/null
+++ b/packages/php/woocommerce-analytics/changelog/fix-analytics-clear-tracks-cookies-on-consent-withdrawal
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fixed
+
+Remove Tracks cookies and stop sending queued events as soon as a visitor withdraws statistics consent.
diff --git a/packages/php/woocommerce-analytics/src/client/analytics.ts b/packages/php/woocommerce-analytics/src/client/analytics.ts
index a58b2749436..4de8f88cb17 100644
--- a/packages/php/woocommerce-analytics/src/client/analytics.ts
+++ b/packages/php/woocommerce-analytics/src/client/analytics.ts
@@ -54,14 +54,13 @@ export class Analytics {
 			this.apiClient.init();
 		}

+		consentManager.addConsentChangeListener( this.handleConsentChange );
+
 		/*
 		 * Initialize the session manager and record the page_view event
 		 * only if the ClickHouse (ch) feature is enabled as these events are relevant exclusively when ClickHouse is active.
 		 */
 		if ( this.features.sessionTracking ) {
-			// Set up consent change listener
-			consentManager.addConsentChangeListener( this.handleConsentChange );
-
 			this.sessionManager.init();
 			const { sessionId, landingPage, isEngaged, isNewSession } = this.sessionManager;

@@ -249,8 +248,16 @@ export class Analytics {
 	handleConsentChange = ( hasConsent: boolean ): void => {
 		if ( ! hasConsent ) {
 			// Consent withdrawn - clear session data if session tracking is enabled
-			this.sessionManager.clearSession();
-		} else if ( ! this.sessionManager.sessionId ) {
+			if ( this.features.sessionTracking ) {
+				this.sessionManager.clearSession();
+			}
+			return;
+		}
+
+		// Withdrawing consent expired `tk_ai`, and init does not run again on this page.
+		this.maybeSetAnonId();
+
+		if ( this.features.sessionTracking && ! this.sessionManager.sessionId ) {
 			// Consent granted - reinitialize session if needed
 			this.sessionManager.init();
 		}
@@ -266,7 +273,8 @@ export class Analytics {
 			// Set a first-party cookie (same domain only, 1 year)
 			const randomToken = generateRandomToken( 18 ); // 18 * 4/3 = 24 (base64 encoded chars)
 			const expires = new Date( Date.now() + 1 * 365 * 24 * 60 * 60 * 1000 ).toUTCString();
-			document.cookie = `tk_ai=${ randomToken }; path=/; secure; samesite=strict; expires=${ expires }`;
+			const secure = window.location.protocol === 'https:' ? ' secure;' : '';
+			document.cookie = `tk_ai=${ randomToken }; path=/;${ secure } samesite=strict; expires=${ expires }`;
 		}
 	}
 }
diff --git a/packages/php/woocommerce-analytics/src/client/api-client.ts b/packages/php/woocommerce-analytics/src/client/api-client.ts
index 1625e5594db..2b4bc5dbbe1 100644
--- a/packages/php/woocommerce-analytics/src/client/api-client.ts
+++ b/packages/php/woocommerce-analytics/src/client/api-client.ts
@@ -5,6 +5,7 @@ import debugFactory from 'debug';
 /**
  * Internal dependencies
  */
+import { consentManager } from './consent';
 import { BATCH_SIZE, DEBOUNCE_DELAY } from './constants';
 import type { ApiEvent, ApiFetchResponse } from './types/shared';

@@ -17,6 +18,7 @@ export class ApiClient {
 	private eventQueue: ApiEvent[] = [];
 	private debounceTimer: number | null = null;
 	private isInitialized: boolean = false;
+	private withdrawalCount: number = 0;
 	/**
 	 * Initialize the API client
 	 */
@@ -29,10 +31,31 @@ export class ApiClient {
 		// Send any pending events when the page is about to unload
 		window.addEventListener( 'beforeunload', this.flush );
 		window.addEventListener( 'pagehide', this.flush );
+		consentManager.addConsentChangeListener( hasConsent => {
+			if ( ! hasConsent ) {
+				this.withdrawalCount++;
+				this.dropQueuedEvents();
+			}
+		} );

 		this.isInitialized = true;
 	};

+	/**
+	 * Drop events that are queued but not sent yet, for when statistics consent is withdrawn.
+	 */
+	private dropQueuedEvents = (): void => {
+		if ( this.debounceTimer ) {
+			clearTimeout( this.debounceTimer );
+			this.debounceTimer = null;
+		}
+
+		if ( this.eventQueue.length > 0 ) {
+			debug( 'Dropping %d queued events because statistics consent was withdrawn', this.eventQueue.length );
+			this.eventQueue = [];
+		}
+	};
+
 	/**
 	 * Add an event to the queue for batch sending
 	 *
@@ -115,6 +138,12 @@ export class ApiClient {
 			return;
 		}

+		// Consent can also be withdrawn without a change event, so the listener may not have dropped the queue.
+		if ( ! consentManager.hasAnalyticsConsent() ) {
+			this.dropQueuedEvents();
+			return;
+		}
+
 		const eventsToSend = [ ...this.eventQueue ];
 		this.eventQueue = [];

@@ -144,6 +173,8 @@ export class ApiClient {
 			return;
 		}

+		const withdrawalCount = this.withdrawalCount;
+
 		try {
 			debug( 'Sending %d events to API', events.length );

@@ -165,6 +196,11 @@ export class ApiClient {
 			debug( 'API response received: %o', data );
 		} catch ( error ) {
 			debug( 'Failed to send events to API: %o', error );
+			// Consent was withdrawn while the request was pending, possibly granted again since.
+			if ( withdrawalCount !== this.withdrawalCount ) {
+				debug( 'Dropping %d failed events because statistics consent was withdrawn', events.length );
+				return;
+			}
 			// Re-add events to queue for potential retry on next event
 			this.eventQueue.unshift( ...events );
 		}
diff --git a/packages/php/woocommerce-analytics/src/client/index.ts b/packages/php/woocommerce-analytics/src/client/index.ts
index c8c0ff457aa..55b72800c4d 100644
--- a/packages/php/woocommerce-analytics/src/client/index.ts
+++ b/packages/php/woocommerce-analytics/src/client/index.ts
@@ -5,6 +5,7 @@
  */
 import './public-path';
 import { consentManager } from './consent';
+import { clearTrackingCookies } from './tracking-cookies';
 import { activateTrackingScript } from './tracking-script';

 /**
@@ -24,6 +25,12 @@ jQuery( () => {
 	consentManager.addConsentChangeListener( ( hasConsent: boolean ) => {
 		if ( hasConsent ) {
 			startTracking();
+		} else {
+			clearTrackingCookies();
+			// Until s.js loads, events wait in this array, and s.js sends them when it arrives.
+			if ( Array.isArray( window._wca ) ) {
+				window._wca.length = 0;
+			}
 		}
 	} );

@@ -31,6 +38,9 @@ jQuery( () => {
 		consentManager.waitForConsentType( () => {
 			if ( consentManager.hasAnalyticsConsent() ) {
 				startTracking();
+			} else {
+				// Consent may have been withdrawn on a page without analytics, after an earlier visit set these.
+				clearTrackingCookies();
 			}
 		} )
 	);
diff --git a/packages/php/woocommerce-analytics/src/client/test/analytics-http.test.ts b/packages/php/woocommerce-analytics/src/client/test/analytics-http.test.ts
new file mode 100644
index 00000000000..de95ed78ae6
--- /dev/null
+++ b/packages/php/woocommerce-analytics/src/client/test/analytics-http.test.ts
@@ -0,0 +1,50 @@
+/**
+ * @jest-environment-options {"url": "http://shop.example.com/"}
+ */
+
+/**
+ * External dependencies
+ */
+import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
+
+/**
+ * Internal dependencies
+ */
+import { Analytics } from '../analytics';
+import SessionManager from '../session-manager';
+
+const changeConsent = ( value: 'allow' | 'deny' ) =>
+	document.dispatchEvent(
+		new CustomEvent( 'wp_listen_for_consent_change', { detail: { statistics: value } } )
+	);
+
+describe( 'Analytics consent changes on an HTTP store', () => {
+	let hasConsent: boolean;
+
+	beforeEach( () => {
+		hasConsent = true;
+		window.wp_has_consent = () => hasConsent;
+	} );
+
+	afterEach( () => {
+		delete window.wp_has_consent;
+		document.cookie = 'tk_ai=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
+	} );
+
+	it( 'sets tk_ai again when statistics consent is granted again on the same page', () => {
+		new Analytics( new SessionManager(), {
+			eventQueue: [],
+			commonProps: {},
+			features: {},
+			pages: {},
+		} ).init();
+		hasConsent = false;
+		changeConsent( 'deny' );
+		document.cookie = 'tk_ai=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
+
+		hasConsent = true;
+		changeConsent( 'allow' );
+
+		expect( document.cookie ).toContain( 'tk_ai=' );
+	} );
+} );
diff --git a/packages/php/woocommerce-analytics/src/client/test/analytics.test.ts b/packages/php/woocommerce-analytics/src/client/test/analytics.test.ts
new file mode 100644
index 00000000000..635ac9aa17f
--- /dev/null
+++ b/packages/php/woocommerce-analytics/src/client/test/analytics.test.ts
@@ -0,0 +1,58 @@
+/**
+ * @jest-environment-options {"url": "https://shop.example.com/"}
+ */
+
+/**
+ * External dependencies
+ */
+import { afterEach, beforeEach, describe, expect, it } from '@jest/globals';
+
+/**
+ * Internal dependencies
+ */
+import { Analytics } from '../analytics';
+import { COOKIE_NAME } from '../constants';
+import SessionManager from '../session-manager';
+
+const expire = ( name: string ) => {
+	document.cookie = `${ name }=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT`;
+};
+
+const changeConsent = ( value: 'allow' | 'deny' ) =>
+	document.dispatchEvent(
+		new CustomEvent( 'wp_listen_for_consent_change', { detail: { statistics: value } } )
+	);
+
+describe( 'Analytics consent changes', () => {
+	let hasConsent: boolean;
+
+	beforeEach( () => {
+		hasConsent = true;
+		window.wp_has_consent = () => hasConsent;
+	} );
+
+	afterEach( () => {
+		delete window.wp_has_consent;
+		expire( 'tk_ai' );
+		expire( COOKIE_NAME );
+	} );
+
+	it( 'sets tk_ai again when statistics consent is granted again on the same page', () => {
+		new Analytics( new SessionManager(), {
+			eventQueue: [],
+			commonProps: {},
+			features: {},
+			pages: {},
+		} ).init();
+		hasConsent = false;
+		changeConsent( 'deny' );
+		// The entry point expires the Tracks cookies on withdrawal.
+		expire( 'tk_ai' );
+
+		hasConsent = true;
+		changeConsent( 'allow' );
+
+		expect( document.cookie ).toContain( 'tk_ai=' );
+		expect( document.cookie ).not.toContain( `${ COOKIE_NAME }=` );
+	} );
+} );
diff --git a/packages/php/woocommerce-analytics/src/client/test/api-client.test.ts b/packages/php/woocommerce-analytics/src/client/test/api-client.test.ts
new file mode 100644
index 00000000000..87ed27cac1d
--- /dev/null
+++ b/packages/php/woocommerce-analytics/src/client/test/api-client.test.ts
@@ -0,0 +1,114 @@
+/**
+ * External dependencies
+ */
+import { afterEach, beforeEach, describe, expect, it, jest } from '@jest/globals';
+
+/**
+ * Internal dependencies
+ */
+import { ApiClient } from '../api-client';
+
+const changeConsent = ( value: 'allow' | 'deny' ) =>
+	document.dispatchEvent(
+		new CustomEvent( 'wp_listen_for_consent_change', { detail: { statistics: value } } )
+	);
+
+describe( 'ApiClient', () => {
+	const sendBeacon = jest.fn( () => true );
+
+	beforeEach( () => {
+		jest.useFakeTimers();
+		Object.defineProperty( navigator, 'sendBeacon', { value: sendBeacon, configurable: true } );
+		window.wcAnalytics = {
+			trackEndpoint: 'https://shop.example/wp-json/woocommerce-analytics/v1/track',
+			eventQueue: [],
+			commonProps: {},
+			features: { proxy: true },
+			pages: {},
+			assets_url: '',
+		};
+	} );
+
+	afterEach( () => {
+		jest.useRealTimers();
+		sendBeacon.mockClear();
+		delete window.wcAnalytics;
+		delete window.wp_has_consent;
+	} );
+
+	it( 'sends queued events after the debounce while statistics consent is given', () => {
+		window.wp_has_consent = () => true;
+		const client = new ApiClient();
+		client.init();
+
+		client.addEvent( 'page_view' );
+		jest.advanceTimersByTime( 1000 );
+
+		expect( sendBeacon ).toHaveBeenCalledTimes( 1 );
+	} );
+
+	it( 'drops events queued before statistics consent was withdrawn', () => {
+		let hasConsent = true;
+		window.wp_has_consent = () => hasConsent;
+		const client = new ApiClient();
+		client.init();
+
+		client.addEvent( 'page_view' );
+		hasConsent = false;
+		jest.advanceTimersByTime( 1000 );
+		window.dispatchEvent( new Event( 'pagehide' ) );
+
+		hasConsent = true;
+		window.dispatchEvent( new Event( 'pagehide' ) );
+
+		expect( sendBeacon ).not.toHaveBeenCalled();
+	} );
+
+	it( 'drops events queued before a withdrawal when consent is granted again before they are sent', () => {
+		let hasConsent = true;
+		window.wp_has_consent = () => hasConsent;
+		const client = new ApiClient();
+		client.init();
+
+		client.addEvent( 'page_view' );
+		hasConsent = false;
+		changeConsent( 'deny' );
+		hasConsent = true;
+		changeConsent( 'allow' );
+		jest.advanceTimersByTime( 1000 );
+
+		expect( sendBeacon ).not.toHaveBeenCalled();
+	} );
+
+	it( 'drops a batch that fails after consent was withdrawn, even when consent is granted again', async () => {
+		let hasConsent = true;
+		window.wp_has_consent = () => hasConsent;
+		sendBeacon.mockReturnValueOnce( false ).mockReturnValueOnce( false );
+		let failFirstRequest: ( error: Error ) => void = () => {};
+		const fetchMock = jest
+			.fn< typeof fetch >()
+			.mockReturnValueOnce(
+				new Promise( ( _resolve, reject ) => {
+					failFirstRequest = reject;
+				} )
+			)
+			.mockResolvedValue( { ok: true, json: async () => ( {} ) } as Response );
+		global.fetch = fetchMock;
+		const client = new ApiClient();
+		client.init();
+
+		client.addEvent( 'before_withdrawal' );
+		client.flush();
+		hasConsent = false;
+		changeConsent( 'deny' );
+		failFirstRequest( new Error( 'Network error' ) );
+		await jest.runAllTimersAsync();
+		hasConsent = true;
+		changeConsent( 'allow' );
+		client.addEvent( 'after_regrant' );
+		client.flush();
+
+		expect( fetchMock ).toHaveBeenCalledTimes( 2 );
+		expect( fetchMock.mock.calls[ 1 ][ 1 ]?.body ).not.toContain( 'before_withdrawal' );
+	} );
+} );
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 5b3cc33a8f5..6b37cfef814 100644
--- a/packages/php/woocommerce-analytics/src/client/test/index.test.ts
+++ b/packages/php/woocommerce-analytics/src/client/test/index.test.ts
@@ -106,6 +106,8 @@ describe( 'client entry point', () => {
 		delete window.wp_has_consent;
 		delete window.waitfor_consent_hook;
 		delete window.wp_consent_type;
+		delete window._wca;
+		document.cookie = 'tk_or=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
 	} );

 	it( 'loads s.js and analytics when statistics consent is given', async () => {
@@ -260,4 +262,52 @@ describe( 'client entry point', () => {
 		expect( isTrackingScriptLoaded() ).toBe( true );
 		expect( mockInitAnalytics ).toHaveBeenCalledTimes( 1 );
 	} );
+
+	it( 'clears the tracking cookies when statistics consent is withdrawn on the page', async () => {
+		window.wp_has_consent = () => true;
+		await loadPage();
+		document.cookie = 'tk_or=1; path=/';
+
+		changeConsent( 'deny' );
+
+		expect( document.cookie ).not.toContain( 'tk_or=' );
+	} );
+
+	it( 'drops the events waiting for s.js when statistics consent is withdrawn before it loads', async () => {
+		window.wp_has_consent = () => true;
+		await loadPage();
+		window._wca = [ { _en: 'woocommerceanalytics_page_view' } ] as unknown as typeof window._wca;
+
+		changeConsent( 'deny' );
+
+		expect( window._wca ).toEqual( [] );
+	} );
+
+	it( 'clears tracking cookies from an earlier visit when statistics consent is denied on load', async () => {
+		window.wp_has_consent = () => false;
+		document.cookie = 'tk_or=1; path=/';
+
+		await loadPage();
+
+		expect( document.cookie ).not.toContain( 'tk_or=' );
+	} );
+
+	it( 'clears tracking cookies from an earlier visit once a delayed WP Consent API script reports no consent', async () => {
+		document.cookie = 'tk_or=1; path=/';
+		await loadPage();
+
+		expect( document.cookie ).toContain( 'tk_or=1' );
+
+		await loadDelayedConsentApi( false );
+
+		expect( document.cookie ).not.toContain( 'tk_or=' );
+	} );
+
+	it( 'keeps the tracking cookies when there is no consent plugin', async () => {
+		document.cookie = 'tk_or=1; path=/';
+
+		await loadPage( 'regular' );
+
+		expect( document.cookie ).toContain( 'tk_or=1' );
+	} );
 } );
diff --git a/packages/php/woocommerce-analytics/src/client/test/tracking-cookies.test.ts b/packages/php/woocommerce-analytics/src/client/test/tracking-cookies.test.ts
new file mode 100644
index 00000000000..2f54830ef5b
--- /dev/null
+++ b/packages/php/woocommerce-analytics/src/client/test/tracking-cookies.test.ts
@@ -0,0 +1,71 @@
+/**
+ * @jest-environment-options {"url": "https://shop.example.co.uk/"}
+ */
+
+/**
+ * External dependencies
+ */
+import { afterEach, describe, expect, it, jest } from '@jest/globals';
+
+/**
+ * Internal dependencies
+ */
+import { clearTrackingCookies } from '../tracking-cookies';
+
+const TRACKING_COOKIE_NAMES = [ 'tk_or', 'tk_lr', 'tk_r3d', 'tk_ai', 'tk_aip', 'tk_qs' ];
+
+const expire = ( name: string, domain = '' ) => {
+	document.cookie = `${ name }=; path=/; ${ domain }expires=Thu, 01 Jan 1970 00:00:00 GMT`;
+};
+
+describe( 'clearTrackingCookies', () => {
+	afterEach( () => {
+		for ( const cookie of document.cookie.split( '; ' ).filter( Boolean ) ) {
+			const name = cookie.split( '=' )[ 0 ];
+			expire( name );
+			expire( name, 'domain=.example.co.uk; ' );
+		}
+	} );
+
+	it( 'expires every tracking cookie on the parent domain s.js uses', () => {
+		for ( const name of TRACKING_COOKIE_NAMES ) {
+			document.cookie = `${ name }=1; domain=.example.co.uk; path=/`;
+		}
+
+		clearTrackingCookies();
+
+		expect( document.cookie ).toBe( '' );
+	} );
+
+	it( 'expires both copies of a cookie set host-only and on the parent domain', () => {
+		document.cookie = 'tk_ai=host; path=/';
+		document.cookie = 'tk_ai=parent; domain=.example.co.uk; path=/';
+
+		clearTrackingCookies();
+
+		expect( document.cookie ).toBe( '' );
+	} );
+
+	it( 'writes a host-only expiry, which browsers keep apart from a same-host domain cookie', () => {
+		document.cookie = 'tk_ai=host; path=/';
+		const setCookie = jest.spyOn( Document.prototype, 'cookie', 'set' );
+
+		clearTrackingCookies();
+
+		expect( setCookie ).toHaveBeenCalledWith( 'tk_ai=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT' );
+		setCookie.mockRestore();
+	} );
+
+	it( 'leaves other cookies alone', () => {
+		document.cookie = 'tk_or=1; domain=.example.co.uk; path=/';
+		document.cookie = 'wp_consent_statistics=deny; path=/';
+		document.cookie = 'woocommerce_cart_hash=abc; domain=.example.co.uk; path=/';
+
+		clearTrackingCookies();
+
+		expect( document.cookie.split( '; ' ).sort() ).toEqual( [
+			'woocommerce_cart_hash=abc',
+			'wp_consent_statistics=deny',
+		] );
+	} );
+} );
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
index 4decb76feb1..4f6db3f6dd8 100644
--- a/packages/php/woocommerce-analytics/src/client/test/tracking-script.test.ts
+++ b/packages/php/woocommerce-analytics/src/client/test/tracking-script.test.ts
@@ -26,6 +26,19 @@ const getScripts = () => Array.from( document.querySelectorAll( 'script' ) );
 describe( 'activateTrackingScript', () => {
 	afterEach( () => {
 		document.head.replaceChildren();
+		delete window.wp_has_consent;
+		document.cookie = 'tk_or=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
+	} );
+
+	it( 'clears the cookies s.js set when consent was withdrawn while it was downloading', () => {
+		window.wp_has_consent = () => false;
+		printHeldTag();
+		activateTrackingScript();
+		document.cookie = 'tk_or=1; path=/';
+
+		getScripts()[ 0 ].dispatchEvent( new Event( 'load' ) );
+
+		expect( document.cookie ).not.toContain( 'tk_or=' );
 	} );

 	it( 'replaces the held s.js tag with one that runs, keeping its attributes', () => {
diff --git a/packages/php/woocommerce-analytics/src/client/tracking-cookies.ts b/packages/php/woocommerce-analytics/src/client/tracking-cookies.ts
new file mode 100644
index 00000000000..123d548d79f
--- /dev/null
+++ b/packages/php/woocommerce-analytics/src/client/tracking-cookies.ts
@@ -0,0 +1,46 @@
+/**
+ * External dependencies
+ */
+import debugFactory from 'debug';
+
+const debug = debugFactory( 'wc-analytics:tracking-cookies' );
+
+/**
+ * Cookies s.js sets for referrer attribution, the anonymous visitor ID and its unsent pixel queue.
+ */
+const TRACKING_COOKIE_NAMES = [ 'tk_or', 'tk_lr', 'tk_r3d', 'tk_ai', 'tk_aip', 'tk_qs' ];
+
+/**
+ * The current host and each of its parent domains, which is where s.js sets these cookies.
+ */
+function getCookieDomains(): string[] {
+	const labels = window.location.hostname.split( '.' );
+	return labels.map( ( _, index ) => '.' + labels.slice( index ).join( '.' ) );
+}
+
+/**
+ * Expire the Tracks cookies on every domain they may have been set on, for when statistics consent is denied.
+ */
+export function clearTrackingCookies(): void {
+	// Not `getCookie()`: it returns null when the same name is set both host-only and on a parent domain.
+	const cookies = document.cookie.split( '; ' );
+	const present = TRACKING_COOKIE_NAMES.filter( name =>
+		cookies.some( cookie => cookie.startsWith( `${ name }=` ) )
+	);
+
+	if ( present.length === 0 ) {
+		return;
+	}
+
+	const expired = 'path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
+	const domains = getCookieDomains();
+
+	for ( const name of present ) {
+		document.cookie = `${ name }=; ${ expired }`;
+		for ( const domain of domains ) {
+			document.cookie = `${ name }=; domain=${ domain }; ${ expired }`;
+		}
+	}
+
+	debug( 'Cleared tracking cookies: %o', present );
+}
diff --git a/packages/php/woocommerce-analytics/src/client/tracking-script.ts b/packages/php/woocommerce-analytics/src/client/tracking-script.ts
index a43a8021d10..d1e159705da 100644
--- a/packages/php/woocommerce-analytics/src/client/tracking-script.ts
+++ b/packages/php/woocommerce-analytics/src/client/tracking-script.ts
@@ -2,6 +2,11 @@
  * External dependencies
  */
 import debugFactory from 'debug';
+/**
+ * Internal dependencies
+ */
+import { consentManager } from './consent';
+import { clearTrackingCookies } from './tracking-cookies';

 const debug = debugFactory( 'wc-analytics:tracking-script' );

@@ -57,5 +62,11 @@ export function activateTrackingScript(): void {
 		}
 	}
 	script.nonce = held.nonce;
+	// s.js sets its cookies as it runs, even when consent was withdrawn while it was downloading.
+	script.addEventListener( 'load', () => {
+		if ( ! consentManager.hasAnalyticsConsent() ) {
+			clearTrackingCookies();
+		}
+	} );
 	held.replaceWith( script );
 }