Commit f81f38f65b5 for woocommerce

commit f81f38f65b544de2e836ccc39cc4a6f45adaf176
Author: Chi-Hsuan Huang <chihsuan.tw@gmail.com>
Date:   Wed Apr 15 21:30:50 2026 +0800

    fix: use bundled momentTz.tz() to prevent plugin conflict crash (#64021)

    * fix: use bundled momentTz.tz() to prevent plugin conflict crash

    Use the bundled moment-timezone static function directly instead of
    relying on the global moment instance having .tz() on its prototype.
    This prevents "tz is not a function" crashes when a third-party plugin
    clobbers window.moment after WooCommerce's bundle loads.

    * test: add regression test for moment.tz plugin conflict scenario

    * fix: address lint error and make prototype restoration exception-safe

    * fix: use jest.spyOn for utcOffset mocks and restore in afterEach

diff --git a/packages/js/date/changelog/fix-moment-tz-plugin-conflict b/packages/js/date/changelog/fix-moment-tz-plugin-conflict
new file mode 100644
index 00000000000..67b028ad6ff
--- /dev/null
+++ b/packages/js/date/changelog/fix-moment-tz-plugin-conflict
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Use bundled moment-timezone directly to prevent crash when plugins clobber global moment
diff --git a/packages/js/date/src/index.ts b/packages/js/date/src/index.ts
index c0adfaa2fae..66a06589e6f 100644
--- a/packages/js/date/src/index.ts
+++ b/packages/js/date/src/index.ts
@@ -184,7 +184,7 @@ export function getStoreTimeZoneMoment() {
 		return moment().utcOffset( timeZone );
 	}

-	return ( moment() as momentTz.Moment ).tz( timeZone );
+	return momentTz.tz( timeZone );
 }

 /**
diff --git a/packages/js/date/src/test/index.ts b/packages/js/date/src/test/index.ts
index 810d28ab1ab..6d79ea307e9 100644
--- a/packages/js/date/src/test/index.ts
+++ b/packages/js/date/src/test/index.ts
@@ -2,6 +2,7 @@
  * 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';
 /**
@@ -1031,9 +1032,17 @@ describe( 'getChartTypeForQuery', () => {
 } );

 describe( 'getStoreTimeZoneMoment', () => {
+	let mockTz: jest.SpyInstance;
+	let utcOffset: jest.SpyInstance;
+
+	afterEach( () => {
+		mockTz?.mockRestore();
+		utcOffset?.mockRestore();
+	} );
+
 	it( 'should return the default moment when no timezone exists', () => {
-		const mockTz = ( moment.prototype.tz = jest.fn() );
-		const utcOffset = ( moment.prototype.utcOffset = jest.fn() );
+		mockTz = jest.spyOn( momentTz, 'tz' );
+		utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );

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

@@ -1046,8 +1055,8 @@ describe( 'getStoreTimeZoneMoment', () => {
 			timeZone: 'Asia/Taipei',
 		};

-		const mockTz = ( moment.prototype.tz = jest.fn() );
-		const utcOffset = ( moment.prototype.utcOffset = jest.fn() );
+		mockTz = jest.spyOn( momentTz, 'tz' ).mockReturnValue( moment() );
+		utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );

 		getStoreTimeZoneMoment();

@@ -1060,8 +1069,8 @@ describe( 'getStoreTimeZoneMoment', () => {
 			timeZone: '+06:00',
 		};

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

 		getStoreTimeZoneMoment();

@@ -1085,8 +1094,8 @@ describe( 'getStoreTimeZoneMoment', () => {
 			},
 		};

-		const mockTz = ( moment.prototype.tz = jest.fn() );
-		const utcOffset = ( moment.prototype.utcOffset = jest.fn() );
+		mockTz = jest.spyOn( momentTz, 'tz' ).mockReturnValue( moment() );
+		utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );

 		getStoreTimeZoneMoment();

@@ -1101,8 +1110,8 @@ describe( 'getStoreTimeZoneMoment', () => {
 			},
 		};

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

 		getStoreTimeZoneMoment();

@@ -1118,14 +1127,41 @@ describe( 'getStoreTimeZoneMoment', () => {
 			},
 		};

-		const mockTz = ( moment.prototype.tz = jest.fn() );
-		const utcOffset = ( moment.prototype.utcOffset = jest.fn() );
+		mockTz = jest.spyOn( momentTz, 'tz' ).mockReturnValue( moment() );
+		utcOffset = jest.spyOn( moment.prototype, 'utcOffset' );

 		getStoreTimeZoneMoment();

 		expect( mockTz ).toHaveBeenCalledWith( 'Europe/London' );
 		expect( utcOffset ).not.toHaveBeenCalled();
 	} );
+
+	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.
+		global.window.wcSettings = {
+			timeZone: 'Asia/Taipei',
+		};
+
+		// 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;
+
+		// 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() );
+
+		try {
+			expect( () => getStoreTimeZoneMoment() ).not.toThrow();
+			expect( mockTz ).toHaveBeenCalledWith( 'Asia/Taipei' );
+		} finally {
+			moment.prototype.tz = originalTz;
+		}
+	} );
 } );

 describe( 'getDateFormatsForIntervalPhp', () => {