Commit 0a434410843 for woocommerce

commit 0a434410843a5d4b20aee47b8bbf6d39612a12eb
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Fri Jun 26 10:10:51 2026 +0800

    Fix Analytics "tz is not a function" crash from a moment.js plugin conflict (#65966)

    * fix: resolve Analytics timezone crash when a plugin clobbers window.moment

    * fix(date): keep named-zone date ranges DST-correct across transitions

    * test: cover getCurrentPeriod and all range boundaries for store-tz DST anchoring

    * fix: guard window in store-tz lookup and restore wcSettings between tests

diff --git a/packages/js/date/changelog/fix-64020-moment-tz-clobber b/packages/js/date/changelog/fix-64020-moment-tz-clobber
new file mode 100644
index 00000000000..6f6892eb7ba
--- /dev/null
+++ b/packages/js/date/changelog/fix-64020-moment-tz-clobber
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Resolve the store timezone offset via date-fns-tz instead of moment-timezone's .tz() so Analytics no longer crashes with "tz is not a function" when a plugin replaces the global window.moment (#64020).
diff --git a/packages/js/date/package.json b/packages/js/date/package.json
index 80fa3b935b4..5e9e7652f9c 100644
--- a/packages/js/date/package.json
+++ b/packages/js/date/package.json
@@ -41,8 +41,8 @@
 		"@types/d3-time-format": "^2.3.4",
 		"@wordpress/date": "catalog:wp-min",
 		"@wordpress/i18n": "catalog:wp-min",
+		"date-fns-tz": "^3.2.0",
 		"moment": "^2.29.4",
-		"moment-timezone": "^0.5.43",
 		"qs": "^6.11.2"
 	},
 	"devDependencies": {
diff --git a/packages/js/date/src/index.ts b/packages/js/date/src/index.ts
index 66a06589e6f..fa56b4b1b3d 100644
--- a/packages/js/date/src/index.ts
+++ b/packages/js/date/src/index.ts
@@ -2,7 +2,7 @@
  * External dependencies
  */
 import moment from 'moment';
-import momentTz from 'moment-timezone';
+import { getTimezoneOffset } from 'date-fns-tz';
 import { find, memoize } from 'lodash';
 import { __ } from '@wordpress/i18n';
 import { parse } from 'qs';
@@ -167,14 +167,28 @@ export function getRangeLabel( after: moment.Moment, before: moment.Moment ) {
 	) }`;
 }

+/**
+ * Reads the configured store time zone from `wcSettings`.
+ *
+ * @return {string | undefined} - IANA zone name or `±HH:mm` offset, if set.
+ */
+function getStoreTimeZoneSetting() {
+	// Optional chaining does not protect the free `window` reference, so guard
+	// it for non-browser environments before falling back to the local moment.
+	if ( typeof window === 'undefined' ) {
+		return undefined;
+	}
+
+	return window.wcSettings?.timeZone || window.wcSettings?.admin?.timeZone;
+}
+
 /**
  * Gets the current time in the store time zone if set.
  *
- * @return {string} - Datetime string.
+ * @return {moment.Moment} - Moment object in the store time zone.
  */
 export function getStoreTimeZoneMoment() {
-	const timeZone =
-		window.wcSettings?.timeZone || window.wcSettings?.admin?.timeZone;
+	const timeZone = getStoreTimeZoneSetting();

 	if ( typeof timeZone !== 'string' || timeZone.length === 0 ) {
 		return moment();
@@ -184,7 +198,64 @@ export function getStoreTimeZoneMoment() {
 		return moment().utcOffset( timeZone );
 	}

-	return momentTz.tz( timeZone );
+	// Named IANA zone (e.g. `America/New_York`). Resolve the current UTC
+	// offset with `date-fns-tz` (which uses the browser `Intl` API) rather
+	// than `moment-timezone`'s `.tz()`: the admin build externalises
+	// `moment-timezone` to the global `window.moment`, so a plugin replacing
+	// `window.moment` strips `.tz` and crashes Analytics (#64020).
+	const offsetInMinutes = getTimezoneOffset( timeZone ) / 60000;
+
+	if ( Number.isNaN( offsetInMinutes ) ) {
+		return moment();
+	}
+
+	return moment().utcOffset( offsetInMinutes );
+}
+
+/**
+ * Re-applies the store time zone's UTC offset for a moment's own date, keeping
+ * its wall-clock time. `getStoreTimeZoneMoment` resolves a named IANA zone's
+ * offset for "now", so a range boundary in a different DST period (e.g. last
+ * year/quarter) would otherwise be an hour off; this corrects each boundary
+ * against its own date. Fixed `±HH:mm` offsets and the no-zone case are
+ * returned unchanged (#64020).
+ *
+ * @param {moment.Moment} date - The moment to anchor.
+ * @return {moment.Moment} - The anchored moment.
+ */
+function anchorToStoreTimeZone( date: moment.Moment ) {
+	const timeZone = getStoreTimeZoneSetting();
+
+	if (
+		typeof timeZone !== 'string' ||
+		timeZone.length === 0 ||
+		[ '+', '-' ].includes( timeZone.charAt( 0 ) )
+	) {
+		return date;
+	}
+
+	const offsetInMinutes =
+		getTimezoneOffset( timeZone, date.toDate() ) / 60000;
+
+	return Number.isNaN( offsetInMinutes )
+		? date
+		: date.utcOffset( offsetInMinutes, true );
+}
+
+/**
+ * Anchors every boundary of a date range to the store time zone.
+ * See {@link anchorToStoreTimeZone}.
+ *
+ * @param {DateValue} range - The computed range.
+ * @return {DateValue} - The range with each boundary anchored.
+ */
+function anchorRangeToStoreTimeZone( range: DateValue ): DateValue {
+	return {
+		primaryStart: anchorToStoreTimeZone( range.primaryStart ),
+		primaryEnd: anchorToStoreTimeZone( range.primaryEnd ),
+		secondaryStart: anchorToStoreTimeZone( range.secondaryStart ),
+		secondaryEnd: anchorToStoreTimeZone( range.secondaryEnd ),
+	};
 }

 /**
@@ -230,12 +301,12 @@ export function getLastPeriod(
 		secondaryEnd = secondaryEnd.clone().endOf( 'month' );
 	}

-	return {
+	return anchorRangeToStoreTimeZone( {
 		primaryStart,
 		primaryEnd,
 		secondaryStart,
 		secondaryEnd,
-	};
+	} );
 }

 /**
@@ -268,12 +339,12 @@ export function getCurrentPeriod(
 			.add( daysSoFar + 1, 'days' )
 			.subtract( 1, 'seconds' );
 	}
-	return {
+	return anchorRangeToStoreTimeZone( {
 		primaryStart,
 		primaryEnd,
 		secondaryStart,
 		secondaryEnd,
-	};
+	} );
 }

 /**
diff --git a/packages/js/date/src/test/index.ts b/packages/js/date/src/test/index.ts
index 6d79ea307e9..980055f47dd 100644
--- a/packages/js/date/src/test/index.ts
+++ b/packages/js/date/src/test/index.ts
@@ -2,7 +2,6 @@
  * External dependencies
  */
 import moment from 'moment';
-import momentTz from 'moment-timezone';
 import { format as formatDate } from '@wordpress/date';
 import { timeFormat as d3TimeFormat } from 'd3-time-format';
 /**
@@ -41,13 +40,6 @@ declare global {
 	}
 }

-jest.mock( 'moment', () => {
-	const m = jest.requireActual( 'moment' );
-	m.prototype.tz = jest.fn().mockImplementation( () => m() );
-
-	return m;
-} );
-
 describe( 'appendTimestamp', () => {
 	it( 'should append `start` timestamp', () => {
 		expect( appendTimestamp( moment( '2018-01-01' ), 'start' ) ).toEqual(
@@ -1032,49 +1024,41 @@ describe( 'getChartTypeForQuery', () => {
 } );

 describe( 'getStoreTimeZoneMoment', () => {
-	let mockTz: jest.SpyInstance;
-	let utcOffset: jest.SpyInstance;
+	const previousWcSettings = global.window.wcSettings;

 	afterEach( () => {
-		mockTz?.mockRestore();
-		utcOffset?.mockRestore();
+		jest.restoreAllMocks();
+		// These tests mutate the global store settings; restore them so a
+		// leaked time zone cannot make later tests order-dependent.
+		global.window.wcSettings = previousWcSettings;
 	} );

 	it( 'should return the default moment when no timezone exists', () => {
-		mockTz = jest.spyOn( momentTz, 'tz' );
-		utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );
+		const utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );

 		expect( getStoreTimeZoneMoment() ).toHaveProperty( '_isAMomentObject' );

-		expect( mockTz ).not.toHaveBeenCalled();
 		expect( utcOffset ).not.toHaveBeenCalled();
 	} );

-	it( 'should use the timezone string when one is set', () => {
+	it( 'should resolve the offset for a named timezone', () => {
 		global.window.wcSettings = {
 			timeZone: 'Asia/Taipei',
 		};

-		mockTz = jest.spyOn( momentTz, 'tz' ).mockReturnValue( moment() );
-		utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );
-
-		getStoreTimeZoneMoment();
-
-		expect( mockTz ).toHaveBeenCalledWith( 'Asia/Taipei' );
-		expect( utcOffset ).not.toHaveBeenCalled();
+		// Taipei is a fixed UTC+8 (no DST) => +480 minutes.
+		expect( getStoreTimeZoneMoment().utcOffset() ).toBe( 480 );
 	} );

 	it( 'should use the utc offset when it is set', () => {
+		const utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );
+
 		global.window.wcSettings = {
 			timeZone: '+06:00',
 		};

-		mockTz = jest.spyOn( momentTz, 'tz' );
-		utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );
-
 		getStoreTimeZoneMoment();

-		expect( mockTz ).not.toHaveBeenCalled();
 		expect( utcOffset ).toHaveBeenCalledWith( '+06:00' );

 		global.window.wcSettings = {
@@ -1083,7 +1067,6 @@ describe( 'getStoreTimeZoneMoment', () => {

 		getStoreTimeZoneMoment();

-		expect( mockTz ).not.toHaveBeenCalled();
 		expect( utcOffset ).toHaveBeenCalledWith( '-04:00' );
 	} );

@@ -1094,72 +1077,139 @@ describe( 'getStoreTimeZoneMoment', () => {
 			},
 		};

-		mockTz = jest.spyOn( momentTz, 'tz' ).mockReturnValue( moment() );
-		utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );
-
-		getStoreTimeZoneMoment();
-
-		expect( mockTz ).toHaveBeenCalledWith( 'America/New_York' );
-		expect( utcOffset ).not.toHaveBeenCalled();
+		// New York is UTC-5 (EST) or UTC-4 (EDT) depending on the date.
+		expect( [ -300, -240 ] ).toContain(
+			getStoreTimeZoneMoment().utcOffset()
+		);
 	} );

 	it( 'should use wcSettings.admin.timeZone utc offset when wcSettings.timeZone is not set', () => {
+		const utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );
+
 		global.window.wcSettings = {
 			admin: {
 				timeZone: '+05:00',
 			},
 		};

-		mockTz = jest.spyOn( momentTz, 'tz' );
-		utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );
-
 		getStoreTimeZoneMoment();

-		expect( mockTz ).not.toHaveBeenCalled();
 		expect( utcOffset ).toHaveBeenCalledWith( '+05:00' );
 	} );

 	it( 'should prefer wcSettings.timeZone over wcSettings.admin.timeZone', () => {
 		global.window.wcSettings = {
-			timeZone: 'Europe/London',
+			timeZone: 'Asia/Taipei',
 			admin: {
 				timeZone: 'America/New_York',
 			},
 		};

-		mockTz = jest.spyOn( momentTz, 'tz' ).mockReturnValue( moment() );
-		utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );
-
-		getStoreTimeZoneMoment();
-
-		expect( mockTz ).toHaveBeenCalledWith( 'Europe/London' );
-		expect( utcOffset ).not.toHaveBeenCalled();
+		// Resolves Taipei (+480), not New York.
+		expect( getStoreTimeZoneMoment().utcOffset() ).toBe( 480 );
 	} );

-	it( 'should use momentTz.tz() static function, not moment().tz() instance method', () => {
-		// Regression test for plugin conflict where a third-party plugin
-		// clobbers window.moment, removing .tz() from new instances.
-		// The fix uses the bundled momentTz.tz() static function which
-		// operates on moment-timezone's closure reference, unaffected
-		// by the global being replaced.
+	it( 'should not rely on the clobberable window.moment.tz (regression for #64020)', () => {
+		// A third-party plugin can replace window.moment with a build that
+		// has no timezone data, removing the `.tz` method. Because the admin
+		// build externalises moment-timezone to window.moment, this used to
+		// crash getStoreTimeZoneMoment with `TypeError: tz is not a function`.
+		// The offset is now resolved via the browser Intl API, so the
+		// missing `.tz` no longer matters.
 		global.window.wcSettings = {
-			timeZone: 'Asia/Taipei',
+			timeZone: 'America/New_York',
+		};
+
+		const momentWithTz = moment as unknown as { tz?: unknown };
+		const momentProtoWithTz = moment.prototype as unknown as {
+			tz?: unknown;
 		};
+		const originalStaticTz = momentWithTz.tz;
+		const originalProtoTz = momentProtoWithTz.tz;
+		delete momentWithTz.tz;
+		delete momentProtoWithTz.tz;
+
+		try {
+			let result: moment.Moment | undefined;

-		// Remove .tz from prototype to simulate clobbered moment instances.
-		const originalTz = moment.prototype.tz;
-		// eslint-disable-next-line @typescript-eslint/no-explicit-any -- need to delete typed property to simulate clobbering
-		delete ( moment.prototype as any ).tz;
+			expect( () => {
+				result = getStoreTimeZoneMoment();
+			} ).not.toThrow();
+
+			expect( [ -300, -240 ] ).toContain( result?.utcOffset() );
+		} finally {
+			momentWithTz.tz = originalStaticTz;
+			momentProtoWithTz.tz = originalProtoTz;
+		}
+	} );

-		// Mock momentTz.tz since its internals also use the shared prototype in tests.
-		// In production, momentTz's closure holds the original moment with .tz intact.
-		mockTz = jest.spyOn( momentTz, 'tz' ).mockReturnValue( moment() );
+	it( 'keeps named-zone range boundaries DST-correct across a transition (#64020)', () => {
+		// "Now" is summer (EDT, UTC-4); the previous-year range lands in
+		// winter (EST, UTC-5). A single "now" offset would leave the boundary
+		// an hour off, so each boundary is re-anchored against its own date.
+		jest.useFakeTimers().setSystemTime(
+			new Date( '2026-07-15T12:00:00Z' )
+		);
+		global.window.wcSettings = { timeZone: 'America/New_York' };

 		try {
-			expect( () => getStoreTimeZoneMoment() ).not.toThrow();
-			expect( mockTz ).toHaveBeenCalledWith( 'Asia/Taipei' );
+			const { primaryStart, primaryEnd, secondaryStart, secondaryEnd } =
+				getLastPeriod( 'year', 'previous_period' );
+
+			// Every boundary lands in winter (EST, -300) even though the
+			// current offset is EDT, and each is anchored against its own date
+			// (not "now"), with its wall-clock preserved.
+			expect( primaryStart.utcOffset() ).toBe( -300 );
+			expect( primaryStart.format( 'YYYY-MM-DDTHH:mm:ss' ) ).toBe(
+				'2025-01-01T00:00:00'
+			);
+			expect( primaryEnd.utcOffset() ).toBe( -300 );
+			expect( primaryEnd.format( 'YYYY-MM-DDTHH:mm:ss' ) ).toBe(
+				'2025-12-31T23:59:59'
+			);
+			expect( secondaryStart.utcOffset() ).toBe( -300 );
+			expect( secondaryStart.format( 'YYYY-MM-DDTHH:mm:ss' ) ).toBe(
+				'2024-01-01T00:00:00'
+			);
+			expect( secondaryEnd.utcOffset() ).toBe( -300 );
+			expect( secondaryEnd.format( 'YYYY-MM-DDTHH:mm:ss' ) ).toBe(
+				'2024-12-31T23:59:59'
+			);
+		} finally {
+			jest.useRealTimers();
+		}
+	} );
+
+	it( 'anchors getCurrentPeriod boundaries to their own dates across a DST transition (#64020)', () => {
+		// "Now" is summer (EDT, -240); the year-to-date range opens in winter
+		// (EST, -300). Each boundary must resolve its own date's offset, so a
+		// single "now" offset is not reused across the range. This also covers
+		// getCurrentPeriod, whose boundary math differs from getLastPeriod.
+		jest.useFakeTimers().setSystemTime(
+			new Date( '2026-07-15T12:00:00Z' )
+		);
+		global.window.wcSettings = { timeZone: 'America/New_York' };
+
+		try {
+			const { primaryStart, primaryEnd, secondaryStart, secondaryEnd } =
+				getCurrentPeriod( 'year', 'previous_year' );
+
+			// January is EST (-300); "now" in July is EDT (-240).
+			expect( primaryStart.utcOffset() ).toBe( -300 );
+			expect( primaryStart.format( 'YYYY-MM-DDTHH:mm:ss' ) ).toBe(
+				'2026-01-01T00:00:00'
+			);
+			expect( primaryEnd.utcOffset() ).toBe( -240 );
+
+			// The previous-year comparison opens in winter and ends in summer,
+			// so its boundaries carry different offsets too.
+			expect( secondaryStart.utcOffset() ).toBe( -300 );
+			expect( secondaryStart.format( 'YYYY-MM-DDTHH:mm:ss' ) ).toBe(
+				'2025-01-01T00:00:00'
+			);
+			expect( secondaryEnd.utcOffset() ).toBe( -240 );
 		} finally {
-			moment.prototype.tz = originalTz;
+			jest.useRealTimers();
 		}
 	} );
 } );
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ccf7ec77ada..93404e3ff1b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1016,15 +1016,15 @@ importers:
       '@wordpress/i18n':
         specifier: catalog:wp-min
         version: 6.6.1
+      date-fns-tz:
+        specifier: ^3.2.0
+        version: 3.2.0(date-fns@4.1.0)
       lodash:
         specifier: ^4.17.0
         version: 4.17.21
       moment:
         specifier: ^2.29.4
         version: 2.30.1
-      moment-timezone:
-        specifier: ^0.5.43
-        version: 0.5.48
       qs:
         specifier: ^6.11.2
         version: 6.15.1
@@ -13664,6 +13664,11 @@ packages:
   date-fns-jalali@4.1.0-0:
     resolution: {integrity: sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==, tarball: https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz}

+  date-fns-tz@3.2.0:
+    resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==, tarball: https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.2.0.tgz}
+    peerDependencies:
+      date-fns: ^3.0.0 || ^4.0.0
+
   date-fns@2.30.0:
     resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==, tarball: https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz}
     engines: {node: '>=0.11'}
@@ -41593,6 +41598,10 @@ snapshots:

   date-fns-jalali@4.1.0-0: {}

+  date-fns-tz@3.2.0(date-fns@4.1.0):
+    dependencies:
+      date-fns: 4.1.0
+
   date-fns@2.30.0:
     dependencies:
       '@babel/runtime': 7.25.7