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