Repository: iamkun/dayjs (JavaScript)

Issue:
objectSupport plugin causes an error when null is passed to dayjs function
**Describe the bug**
There is an bug, which causes an error `Cannot read properties of null (reading 'constructor')` when null is passed to dayjs function while objectSupport plugin is enabled e.g. `dayjs(null)`

**Expected behavior**
dayjs function should execute and create date object with Invalid Date.

**Information**
Day.js Version 1.11.7
OS: Ubuntu Linux
Browser - chrome

**Fix**
https://github.com/iamkun/dayjs/blob/00c223b7e92970d07557133994fcb225a6d4c960/src/plugin/objectSupport/index.js#L4
Change this line to `&& !proto.$utils().u(obj) && obj !== null && (obj.constructor.name === 'Object');`


Hints:
ref: c9370ea96bf420439ee7eaa4146e8ed643160312
PR opened at https://github.com/iamkun/dayjs/pull/2342

Ping @iamkun please merge/release with semver patch, thank you 🙏 

Patch:
diff --git a/src/index.js b/src/index.js
--- a/src/index.js
+++ b/src/index.js
@@ -278,34 +278,63 @@ class Dayjs {
       return isLowercase ? m.toLowerCase() : m
     })
 
-    const matches = {
-      YY: String(this.$y).slice(-2),
-      YYYY: Utils.s(this.$y, 4, '0'),
-      M: $M + 1,
-      MM: Utils.s($M + 1, 2, '0'),
-      MMM: getShort(locale.monthsShort, $M, months, 3),
-      MMMM: getShort(months, $M),
-      D: this.$D,
-      DD: Utils.s(this.$D, 2, '0'),
-      d: String(this.$W),
-      dd: getShort(locale.weekdaysMin, this.$W, weekdays, 2),
-      ddd: getShort(locale.weekdaysShort, this.$W, weekdays, 3),
-      dddd: weekdays[this.$W],
-      H: String($H),
-      HH: Utils.s($H, 2, '0'),
-      h: get$H(1),
-      hh: get$H(2),
-      a: meridiemFunc($H, $m, true),
-      A: meridiemFunc($H, $m, false),
-      m: String($m),
-      mm: Utils.s($m, 2, '0'),
-      s: String(this.$s),
-      ss: Utils.s(this.$s, 2, '0'),
-      SSS: Utils.s(this.$ms, 3, '0'),
-      Z: zoneStr // 'ZZ' logic below
+    const matches = (match) => {
+      switch (match) {
+        case 'YY':
+          return String(this.$y).slice(-2)
+        case 'YYYY':
+          return Utils.s(this.$y, 4, '0')
+        case 'M':
+          return $M + 1
+        case 'MM':
+          return Utils.s($M + 1, 2, '0')
+        case 'MMM':
+          return getShort(locale.monthsShort, $M, months, 3)
+        case 'MMMM':
+          return getShort(months, $M)
+        case 'D':
+          return this.$D
+        case 'DD':
+          return Utils.s(this.$D, 2, '0')
+        case 'd':
+          return String(this.$W)
+        case 'dd':
+          return getShort(locale.weekdaysMin, this.$W, weekdays, 2)
+        case 'ddd':
+          return getShort(locale.weekdaysShort, this.$W, weekdays, 3)
+        case 'dddd':
+          return weekdays[this.$W]
+        case 'H':
+          return String($H)
+        case 'HH':
+          return Utils.s($H, 2, '0')
+        case 'h':
+          return get$H(1)
+        case 'hh':
+          return get$H(2)
+        case 'a':
+          return meridiemFunc($H, $m, true)
+        case 'A':
+          return meridiemFunc($H, $m, false)
+        case 'm':
+          return String($m)
+        case 'mm':
+          return Utils.s($m, 2, '0')
+        case 's':
+          return String(this.$s)
+        case 'ss':
+          return Utils.s(this.$s, 2, '0')
+        case 'SSS':
+          return Utils.s(this.$ms, 3, '0')
+        case 'Z':
+          return zoneStr // 'ZZ' logic below
+        default:
+          break
+      }
+      return null
     }
 
-    return str.replace(C.REGEX_FORMAT, (match, $1) => $1 || matches[match] || zoneStr.replace(':', '')) // 'ZZ'
+    return str.replace(C.REGEX_FORMAT, (match, $1) => $1 || matches(match) || zoneStr.replace(':', '')) // 'ZZ'
   }
 
   utcOffset() {
@@ -319,18 +348,38 @@ class Dayjs {
     const that = dayjs(input)
     const zoneDelta = (that.utcOffset() - this.utcOffset()) * C.MILLISECONDS_A_MINUTE
     const diff = this - that
-    let result = Utils.m(this, that)
-
-    result = {
-      [C.Y]: result / 12,
-      [C.M]: result,
-      [C.Q]: result / 3,
-      [C.W]: (diff - zoneDelta) / C.MILLISECONDS_A_WEEK,
-      [C.D]: (diff - zoneDelta) / C.MILLISECONDS_A_DAY,
-      [C.H]: diff / C.MILLISECONDS_A_HOUR,
-      [C.MIN]: diff / C.MILLISECONDS_A_MINUTE,
-      [C.S]: diff / C.MILLISECONDS_A_SECOND
-    }[unit] || diff // milliseconds
+    const getMonth = () => Utils.m(this, that)
+
+    let result
+    switch (unit) {
+      case C.Y:
+        result = getMonth() / 12
+        break
+      case C.M:
+        result = getMonth()
+        break
+      case C.Q:
+        result = getMonth() / 3
+        break
+      case C.W:
+        result = (diff - zoneDelta) / C.MILLISECONDS_A_WEEK
+        break
+      case C.D:
+        result = (diff - zoneDelta) / C.MILLISECONDS_A_DAY
+        break
+      case C.H:
+        result = diff / C.MILLISECONDS_A_HOUR
+        break
+      case C.MIN:
+        result = diff / C.MILLISECONDS_A_MINUTE
+        break
+      case C.S:
+        result = diff / C.MILLISECONDS_A_SECOND
+        break
+      default:
+        result = diff // milliseconds
+        break
+    }
 
     return float ? result : Utils.a(result)
   }
diff --git a/src/plugin/duration/index.js b/src/plugin/duration/index.js
--- a/src/plugin/duration/index.js
+++ b/src/plugin/duration/index.js
@@ -257,6 +257,15 @@ class Duration {
   asYears() { return this.as('years') }
 }
 
+const manipulateDuration = (date, duration, k) =>
+  date.add(duration.years() * k, 'y')
+    .add(duration.months() * k, 'M')
+    .add(duration.days() * k, 'd')
+    .add(duration.hours() * k, 'h')
+    .add(duration.minutes() * k, 'm')
+    .add(duration.seconds() * k, 's')
+    .add(duration.milliseconds() * k, 'ms')
+
 export default (option, Dayjs, dayjs) => {
   $d = dayjs
   $u = dayjs().$utils()
@@ -268,12 +277,20 @@ export default (option, Dayjs, dayjs) => {
 
   const oldAdd = Dayjs.prototype.add
   const oldSubtract = Dayjs.prototype.subtract
+
   Dayjs.prototype.add = function (value, unit) {
-    if (isDuration(value)) value = value.asMilliseconds()
+    if (isDuration(value)) {
+      return manipulateDuration(this, value, 1)
+    }
+
     return oldAdd.bind(this)(value, unit)
   }
+
   Dayjs.prototype.subtract = function (value, unit) {
-    if (isDuration(value)) value = value.asMilliseconds()
+    if (isDuration(value)) {
+      return manipulateDuration(this, value, -1)
+    }
+
     return oldSubtract.bind(this)(value, unit)
   }
 }
diff --git a/src/plugin/minMax/index.js b/src/plugin/minMax/index.js
--- a/src/plugin/minMax/index.js
+++ b/src/plugin/minMax/index.js
@@ -1,12 +1,18 @@
 export default (o, c, d) => {
   const sortBy = (method, dates) => {
-    if (!dates || !dates.length || !dates[0] || (dates.length === 1 && !dates[0].length)) {
+    if (
+      !dates ||
+      !dates.length ||
+      (dates.length === 1 && !dates[0]) ||
+      (dates.length === 1 && Array.isArray(dates[0]) && !dates[0].length)
+    ) {
       return null
     }
     if (dates.length === 1 && dates[0].length > 0) {
       [dates] = dates
     }
-    let result
+    dates = dates.filter(date => date)
+    let result;
     [result] = dates
     for (let i = 1; i < dates.length; i += 1) {
       if (!dates[i].isValid() || dates[i][method](result)) {
diff --git a/src/plugin/objectSupport/index.js b/src/plugin/objectSupport/index.js
--- a/src/plugin/objectSupport/index.js
+++ b/src/plugin/objectSupport/index.js
@@ -1,6 +1,6 @@
 export default (o, c, dayjs) => {
   const proto = c.prototype
-  const isObject = obj => !(obj instanceof Date) && !(obj instanceof Array)
+  const isObject = obj => obj !== null && !(obj instanceof Date) && !(obj instanceof Array)
         && !proto.$utils().u(obj) && (obj.constructor.name === 'Object')
   const prettyUnit = (u) => {
     const unit = proto.$utils().p(u)
diff --git a/types/plugin/minMax.d.ts b/types/plugin/minMax.d.ts
--- a/types/plugin/minMax.d.ts
+++ b/types/plugin/minMax.d.ts
@@ -4,8 +4,8 @@ declare const plugin: PluginFunc
 export = plugin
 
 declare module 'dayjs' {
-  export function max(dayjs: Dayjs[]): Dayjs
-  export function max(...dayjs: Dayjs[]): Dayjs
-  export function min(dayjs: Dayjs[]): Dayjs
-  export function min(...dayjs: Dayjs[]): Dayjs
+  export function max(dayjs: Dayjs[]): Dayjs | null
+  export function max(...dayjs: Dayjs[]): Dayjs | null
+  export function min(dayjs: Dayjs[]): Dayjs | null
+  export function min(...dayjs: Dayjs[]): Dayjs | null
 }


Test patch:
diff --git a/test/display.test.js b/test/display.test.js
--- a/test/display.test.js
+++ b/test/display.test.js
@@ -264,11 +264,7 @@ it('As ISO 8601 String -> toISOString e.g. 2013-02-04T22:44:30.652Z', () => {
 it('Year 1 formatted with YYYY should pad with zeroes', () => {
   const date = new Date(1, 0, 1)
   date.setUTCFullYear(1) // Required because 0-99 are parsed as 19xx in JS: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/Date#year
-  expect(dayjs(date).format('YYYY')).toBe('0001')
-})
-
-it('Year 1 formatting matches moment format', () => {
-  const date = new Date(1, 0, 1)
-  date.setUTCFullYear(1)
-  expect(dayjs(date).format('YYYY')).toBe(moment(date).format('YYYY'))
+  const res = dayjs(date).format('YYYY')
+  expect(res.slice(0, 3)).toBe('000') // because of timezone, the result might be 0000 0001 or 0002
+  expect(res).toBe(moment(date).format('YYYY'))
 })
diff --git a/test/plugin/duration.test.js b/test/plugin/duration.test.js
--- a/test/plugin/duration.test.js
+++ b/test/plugin/duration.test.js
@@ -182,6 +182,10 @@ test('Add duration', () => {
   const a = dayjs('2020-10-01')
   const days = dayjs.duration(2, 'days')
   expect(a.add(days).format('YYYY-MM-DD')).toBe('2020-10-03')
+
+  const b = dayjs('2023-02-01 00:00:00')
+  const p = dayjs.duration('P1Y1M1DT1H1M1S')
+  expect(b.add(p).format('YYYY-MM-DD HH:mm:ss')).toBe('2024-03-02 01:01:01')
 })
 
 describe('Subtract', () => {
@@ -194,6 +198,10 @@ test('Subtract duration', () => {
   const a = dayjs('2020-10-20')
   const days = dayjs.duration(2, 'days')
   expect(a.subtract(days).format('YYYY-MM-DD')).toBe('2020-10-18')
+
+  const b = dayjs('2023-03-02 02:02:02')
+  const p = dayjs.duration('P1Y1M1DT1H1M1S')
+  expect(b.subtract(p).format('YYYY-MM-DD HH:mm:ss')).toBe('2022-02-01 01:01:01')
 })
 
 describe('Seconds', () => {
diff --git a/test/plugin/minMax.test.js b/test/plugin/minMax.test.js
--- a/test/plugin/minMax.test.js
+++ b/test/plugin/minMax.test.js
@@ -55,3 +55,17 @@ it('If Invalid Date return Invalid Date', () => {
   expect(dayjs.min([arg1, arg2, arg3, arg4]).format())
     .toBe(arg4.format())
 })
+
+it('Ignore if exists an "null" argument', () => {
+  expect(dayjs.max(null, null, arg1, arg2, null, arg3).format())
+    .toBe(arg1.format())
+  expect(dayjs.min([null, null, arg1, arg2, null, arg3]).format())
+    .toBe(arg3.format())
+})
+
+it('Return the only date if just provided one argument', () => {
+  expect(dayjs.max(arg1).format())
+    .toBe(arg1.format())
+  expect(dayjs.min([arg1]).format())
+    .toBe(arg1.format())
+})
diff --git a/test/plugin/objectSupport.test.js b/test/plugin/objectSupport.test.js
--- a/test/plugin/objectSupport.test.js
+++ b/test/plugin/objectSupport.test.js
@@ -140,6 +140,12 @@ it('Constructor from Object UTC', () => {
     expect(moment.utc(tests[i][0]).format(fmt)).toBe(result)
   }
 })
+
+it('Constructor from null should return Invalid Date', () => {
+  expect(dayjs(null).isValid()).toBe(false)
+  expect(moment(null).isValid()).toBe(false)
+})
+
 it('Set from Object', () => {
   for (let i = 0; i < tests.length; i += 1) {
     expect(dayjs(now).set(tests[i][0]).format(fmt)).toBe(moment(now).set(tests[i][0]).format(fmt))
@@ -384,3 +390,11 @@ it('add decimal values of days and months', () => {
   expect(dayjs([2016, 7, 1]).add(1.6, 'years').format('YYYY-MM-DD')).toBe('2017-07-01')
   expect(dayjs([2016, 1, 1]).add(1.1, 'quarters').format('YYYY-MM-DD')).toBe('2016-04-01')
 })
+
+it('returns valid date on undefined', () => {
+  expect(dayjs().isValid()).toBe(true)
+})
+
+it('returns invalid date on null', () => {
+  expect(dayjs(null).isValid()).toBe(false)
+})