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', () => {