Repository: tailwindlabs/tailwindcss
Pull request #2700 (2020-10-29 09:57:59)

Allow merging of arrays with objects when using extend
Currently we have the ability to use the `extend` keyword in our theme config:

```js
modul.exports = {
  theme: {
    extend: {
      colors: {
        red: {
          750: '#ff0000'
        }
      }
    }
  }
}
```

We introduced automatic deep-merging for Tailwind 2.0; The expected output of that snippet above means that a new color for red will be added, but the other colors (100 - 900) still exist. The deep merging of objects works as expected.

We currently also have an exception to this rule, for example when you look at the fontFamily, the values of each font is an array. This means that if you want to set the `sans` font family to something completely different, you can do it by changing the array. Note that the extend functionality *does not* merge arrays.

```js
modul.exports = {
  theme: {
    extend: {
      fonts: {
        sans: ['my-sans-font']
      }
    }
  }
}
```

But here is an issue I faced while working on the `typography` plugin. In the typography plugin we do have an array with default values, and we can extend those values. Each "value" in the array is an object. The reason why is so that we can do something like this:

```js
modul.exports = {
  theme: {
    typography: {
      // Defining multiple values, so that we know that the `a` keys won't collide. 
      css: [{ a: { backgroundColor: 'red' }}, { a: { color: 'pink' }}]
    },
    extend: {
      typography: {
        css: [{ a: { underline: 'none' } }]
      }
    }
  }
}
```

The issue now is that we *don't* merge arrays, in other words, it is impossible to extend the typography styles without a breaking change.
This PR makes a few assumptions, but I think that those assumptions are ok.

What this PR does:

1. If we already have an array, and the first item of the array is an object (than we know that we don't have "simple" values like in the font family)
   1. If the incoming value is an object, push it to the array
   2. If the incoming value is an array, concat them together (to prevent nested arrays)
2. If we already have an object
   1. If the incoming value is an object, merge it (existing behaviour)
   2. If the incoming value is an array of objects, make an array where the first value is the existing object and the other values is the incoming array
3. If the incoming value is a "simple" array, override the existing value (existing behavior - for font families)


Patch:
diff --git a/src/util/resolveConfig.js b/src/util/resolveConfig.js
index 7b6c9c44..774512fd 100644
--- a/src/util/resolveConfig.js
+++ b/src/util/resolveConfig.js
@@ -7,6 +7,8 @@ import map from 'lodash/map'
 import get from 'lodash/get'
 import uniq from 'lodash/uniq'
 import toPath from 'lodash/toPath'
+import head from 'lodash/head'
+import isPlainObject from 'lodash/isPlainObject'
 import negateValue from './negateValue'
 import { corePluginList } from '../corePluginList'
 import configurePlugins from './configurePlugins'
@@ -67,8 +69,24 @@ function mergeThemes(themes) {
   }
 }
 
-function mergeExtensionCustomizer(_merged, value) {
-  if (Array.isArray(value)) return value
+function mergeExtensionCustomizer(merged, value) {
+  // When we have an array of objects, we do want to merge it
+  if (Array.isArray(merged) && isPlainObject(head(merged))) {
+    return merged.concat(value)
+  }
+
+  // When the incoming value is an array, and the existing config is an object, prepend the existing object
+  if (Array.isArray(value) && isPlainObject(head(value)) && isPlainObject(merged)) {
+    return [merged, ...value]
+  }
+
+  // Override arrays (for example for font-families, box-shadows, ...)
+  if (Array.isArray(value)) {
+    return value
+  }
+
+  // Execute default behaviour
+  return undefined
 }
 
 function mergeExtensions({ extend, ...theme }) {


Test patch:
diff --git a/__tests__/resolveConfig.test.js b/__tests__/resolveConfig.test.js
index e7a728d6..b36f8c8c 100644
--- a/__tests__/resolveConfig.test.js
+++ b/__tests__/resolveConfig.test.js
@@ -810,7 +810,7 @@ test('theme values in the extend section can extend values that are depended on
   })
 })
 
-test('theme values in the extend section are not deeply merged', () => {
+test('theme values in the extend section are not deeply merged when they are simple arrays', () => {
   const userConfig = {
     theme: {
       extend: {
@@ -856,6 +856,76 @@ test('theme values in the extend section are not deeply merged', () => {
   })
 })
 
+test('theme values in the extend section are deeply merged, when they are arrays of objects', () => {
+  const userConfig = {
+    theme: {
+      extend: {
+        typography: {
+          ArrayArray: {
+            css: [{ a: { backgroundColor: 'red' } }, { a: { color: 'green' } }],
+          },
+          ObjectArray: {
+            css: { a: { backgroundColor: 'red' } },
+          },
+          ArrayObject: {
+            css: [{ a: { backgroundColor: 'red' } }, { a: { color: 'green' } }],
+          },
+        },
+      },
+    },
+  }
+
+  const defaultConfig = {
+    prefix: '-',
+    important: false,
+    separator: ':',
+    theme: {
+      typography: {
+        ArrayArray: {
+          css: [{ a: { underline: 'none' } }],
+        },
+        ObjectArray: {
+          css: [{ a: { underline: 'none' } }],
+        },
+        ArrayObject: {
+          css: { a: { underline: 'none' } },
+        },
+      },
+    },
+    variants: {},
+  }
+
+  const result = resolveConfig([userConfig, defaultConfig])
+
+  expect(result).toMatchObject({
+    prefix: '-',
+    important: false,
+    separator: ':',
+    theme: {
+      typography: {
+        ArrayArray: {
+          css: [
+            { a: { underline: 'none' } },
+            { a: { backgroundColor: 'red' } },
+            { a: { color: 'green' } },
+          ],
+        },
+        ObjectArray: {
+          css: [{ a: { underline: 'none' } }, { a: { backgroundColor: 'red' } }],
+        },
+        ArrayObject: {
+          css: [
+            { a: { underline: 'none' } },
+            { a: { backgroundColor: 'red' } },
+            { a: { color: 'green' } },
+          ],
+        },
+      },
+    },
+    variants: {},
+  })
+})
+
 test('the theme function can use a default value if the key is missing', () => {
   const userConfig = {
     theme: {