diff --git a/src/plugin/customParseFormat/index.js b/src/plugin/customParseFormat/index.js index c7082833c..9bfb85251 100644 --- a/src/plugin/customParseFormat/index.js +++ b/src/plugin/customParseFormat/index.js @@ -35,6 +35,7 @@ const addInput = function (property) { const zoneExpressions = [matchOffset, function (input) { const zone = this.zone || (this.zone = {}) zone.offset = offsetFromString(input) + zone.input = input }] const getLocalePart = (name) => { @@ -178,9 +179,31 @@ function makeParser(format) { } } +const isStrictMatch = (input, format, zone, instance) => { + // A `Z`/`ZZ` token is rendered by `format()` as the *local* timezone offset, so the + // naive strict round-trip comparison below would reject valid inputs whose offset + // differs from the local one. Compare instead in the input's own offset, which is + // equivalent to re-rendering the parsed instant in UTC. + const wallClock = new Date(instance.$d.getTime() - (zone.offset * 60 * 1000)) + const shadow = instance.clone() + shadow.$d = wallClock + shadow.$u = true + shadow.utcOffset = () => 0 + shadow.$y = wallClock.getUTCFullYear() + shadow.$M = wallClock.getUTCMonth() + shadow.$D = wallClock.getUTCDate() + shadow.$W = wallClock.getUTCDay() + shadow.$H = wallClock.getUTCHours() + shadow.$m = wallClock.getUTCMinutes() + shadow.$s = wallClock.getUTCSeconds() + shadow.$ms = wallClock.getUTCMilliseconds() + const canonical = format.indexOf('ZZ') > -1 ? '+0000' : '+00:00' + return input.replace(zone.input, canonical) === shadow.format(format) +} + const parseFormattedInput = (input, format, utc, dayjs) => { try { - if (['x', 'X'].indexOf(format) > -1) return new Date((format === 'X' ? 1000 : 1) * input) + if (['x', 'X'].indexOf(format) > -1) return { date: new Date((format === 'X' ? 1000 : 1) * input) } const parser = makeParser(format) const { year, month, day, hours, minutes, seconds, milliseconds, zone, week @@ -197,19 +220,22 @@ const parseFormattedInput = (input, format, utc, dayjs) => { const s = seconds || 0 const ms = milliseconds || 0 if (zone) { - return new Date(Date.UTC(y, M, d, h, m, s, ms + (zone.offset * 60 * 1000))) + return { + date: new Date(Date.UTC(y, M, d, h, m, s, ms + (zone.offset * 60 * 1000))), + zone + } } if (utc) { - return new Date(Date.UTC(y, M, d, h, m, s, ms)) + return { date: new Date(Date.UTC(y, M, d, h, m, s, ms)) } } let newDate newDate = new Date(y, M, d, h, m, s, ms) if (week) { newDate = dayjs(newDate).week(week).toDate() } - return newDate + return { date: newDate } } catch (e) { - return new Date('') // Invalid Date + return { date: new Date('') } // Invalid Date } } @@ -239,13 +265,16 @@ export default (o, C, d) => { if (!isStrictWithoutLocale && pl) { locale = d.Ls[pl] } - this.$d = parseFormattedInput(date, format, utc, d) + const result = parseFormattedInput(date, format, utc, d) + this.$d = result.date this.init() + const { zone } = result if (pl && pl !== true) this.$L = this.locale(pl).$L // use != to treat // input number 1410715640579 and format string '1410715640579' equal - // eslint-disable-next-line eqeqeq - if (isStrict && date != this.format(format)) { + if (isStrict && (zone ? !isStrictMatch(date, format, zone, this) + // eslint-disable-next-line eqeqeq + : date != this.format(format))) { this.$d = new Date('') } // reset global locale to make parallel unit test diff --git a/test/plugin/customParseFormat.test.js b/test/plugin/customParseFormat.test.js index fb4030176..1855c6af2 100644 --- a/test/plugin/customParseFormat.test.js +++ b/test/plugin/customParseFormat.test.js @@ -127,6 +127,44 @@ describe('Timezone Offset', () => { }) }) +describe('strict mode with timezone offset', () => { + it('parses ISO 8601 zulu time in strict mode', () => { + const input = '2024-08-19T10:30:00Z' + const format = 'YYYY-MM-DDTHH:mm:ssZ' + expect(dayjs(input, format, true).isValid()).toBe(true) + expect(moment(input, format, true).isValid()).toBe(true) + expect(dayjs(input, format, true).valueOf()).toBe(moment(input, format, true).valueOf()) + }) + it('parses ISO 8601 with milliseconds in strict mode', () => { + const input = '2021-01-26T15:38:43.000Z' + const format = 'YYYY-MM-DDTHH:mm:ss.SSSZ' + expect(dayjs(input, format, true).isValid()).toBe(true) + expect(dayjs(input, format, true).valueOf()).toBe(moment(input, format, true).valueOf()) + }) + it('parses a non-local timezone offset in strict mode', () => { + const input = '2020-12-01T20:00:00+09' + const format = 'YYYY-MM-DD[T]HH:mm:ssZZ' + expect(dayjs(input, format, true).isValid()).toBe(true) + expect(dayjs(input, format, true).valueOf()).toBe(moment(input, format, true).valueOf()) + const input2 = '09/04/2024 21:12:50 +00:00' + const format2 = 'MM/DD/YYYY HH:mm:ss Z' + expect(dayjs(input2, format2, true).isValid()).toBe(true) + expect(dayjs(input2, format2, true).valueOf()).toBe(moment(input2, format2, true).valueOf()) + }) + it('parses zulu time against a ZZ token in strict mode', () => { + const input = '2024-08-19T10:30:00Z' + const format = 'YYYY-MM-DDTHH:mm:ssZZ' + expect(dayjs(input, format, true).isValid()).toBe(true) + expect(moment(input, format, true).isValid()).toBe(true) + }) + it('still rejects an out-of-range date in strict mode', () => { + const input = '2024-02-31T22:03:08Z' + const format = 'YYYY-MM-DDTHH:mm:ssZ' + expect(dayjs(input, format, true).isValid()).toBe(false) + expect(moment(input, format, true).isValid()).toBe(false) + }) +}) + it('parse hh:mm', () => { const input = '12:00' const format = 'hh:mm'