Repository: tailwindlabs/tailwindcss
Pull request #2679 (2020-10-26 13:02:55)

Merge `extend` objects deeply by default
_Description by @adamwathan_

---

This PR changes how the `extend` behavior under `theme` works by default, and merges extensions deeply/recursively instead of shallowly.

Here's a simple example — given this input:

```js
module.exports = {
  theme: {
    extend: {
      colors: {
        red: {
          450: '#f45d5d'
        }
      }
    }
  }
}
```

...historically, you'd get this output:

```js
module.exports = {
  theme: {
    colors: {
      // ...
      red: {
        450: '#f45d5d'
      }
    }
  }
}
```

All of the `red` values would be _gone_, only `450` would exist because the new `red` object replaced the entire old `red` object.

After this PR, you'd get this output:

```js
module.exports = {
  theme: {
    colors: {
      // ...
      red: {
        50: '#fef2f2',
        100: '#fee2e2',
        200: '#fecaca',
        300: '#fca5a5',
        450: '#f45d5d'
        400: '#f87171',
        500: '#ef4444',
        600: '#dc2626',
        700: '#b91c1c',
        800: '#991b1b',
        900: '#7f1d1d',
      },
    }
  }
}
```

...where `450` is merged with the existing object.

This is a breaking change because some people might be depending on this behavior, especially third-party plugins that might have been authored with this behavior in mind when designing their own customization API.

This is one of the most common problems people run into when trying to do stuff like this in their config file though, so we think it's worth it to correct this default.

If someone did want to replace the entire color `red` after this PR is merged, they could still do so using one of two ways:

**1. Replace all of the same sub-values**

If your new color uses the same `50` – `900` scale as the built-in color, then things will just work:

```js
module.exports = {
  theme: {
    extend: {
      colors: {
        red: {
          50: '#fef3f2',
          100: '#fee3e2',
          200: '#fedaca',
          300: '#fca6a5',
          400: '#f97171',
          500: '#ef5444',
          600: '#dd2626',
          700: '#ba1c1c',
          800: '#9a1b1b',
          900: '#7f2d1d',
        }
      }
    }
  }
}
```

**2. Replace the color without using `extend`**

If you want to use a different naming scheme, don't use `extend` at all when overriding the values:

```js
module.exports = {
  theme: {
    colors: {
      red: {
        lightest: '#fef3f2',
        lighter: '#fedaca',
        light: '#f97171',
        DEFAULT: '#ef5444',
        dark: '#dd2626',
        darker: '#9a1b1b',
        darkest: '#7f2d1d',
      }
    }
  }
}
```

This will override _all_ of the colors so you will have to redefine each one you need, but you can do that like so:

```js
const defaultTheme = require('tailwindcss/defaultTheme')

module.exports = {
  theme: {
    colors: {
      ...defaultTheme.colors,
      red: {
        lightest: '#fef3f2',
        lighter: '#fedaca',
        light: '#f97171',
        DEFAULT: '#ef5444',
        dark: '#dd2626',
        darker: '#9a1b1b',
        darkest: '#7f2d1d',
      }
    }
  }
}
```

If you are renaming everything like this though you are likely already replacing all of the colors so this won't even be an issue.

---

Colors are the only place where this is applicable in the default theme (arrays like in `fontFamily` are _not_ deep merged), so we don't expect this will really have a big impact. If anything, I expect most people are already working around this by doing stuff like this:

```js
const defaultTheme = require('tailwindcss/defaultTheme')

module.exports = {
  theme: {
    extend: {
      colors: {
        red: {
          ...defaultTheme.colors.red,
          450: '#f45d5d'
        }
      }
    }
  }
}
```

...so they'll actually be able to just delete some of that workaround code that is no longer necessary.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1d9a19df..703b15f2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 ### Added
 
 - Support flattening deeply nested color objects ([#2148](https://github.com/tailwindlabs/tailwindcss/pull/2148))
+- Support defining presets as functions ([#2680](https://github.com/tailwindlabs/tailwindcss/pull/2680))
 
 ## [2.0.0-alpha.2] - 2020-10-25
 
diff --git a/src/util/getAllConfigs.js b/src/util/getAllConfigs.js
index 5602c7e6..3e65d5db 100644
--- a/src/util/getAllConfigs.js
+++ b/src/util/getAllConfigs.js
@@ -1,10 +1,10 @@
 import defaultConfig from '../../stubs/defaultConfig.stub.js'
 import { flagEnabled } from '../featureFlags'
-import { flatMap, get } from 'lodash'
+import { flatMap, get, isFunction } from 'lodash'
 
 export default function getAllConfigs(config) {
   const configs = flatMap([...get(config, 'presets', [defaultConfig])].reverse(), (preset) => {
-    return getAllConfigs(preset)
+    return getAllConfigs(isFunction(preset) ? preset() : preset)
   })
 
   const features = {
diff --git a/src/util/resolveConfig.js b/src/util/resolveConfig.js
index f682214b..7b6c9c44 100644
--- a/src/util/resolveConfig.js
+++ b/src/util/resolveConfig.js
@@ -67,20 +67,23 @@ function mergeThemes(themes) {
   }
 }
 
+function mergeExtensionCustomizer(_merged, value) {
+  if (Array.isArray(value)) return value
+}
+
 function mergeExtensions({ extend, ...theme }) {
   return mergeWith(theme, extend, (themeValue, extensions) => {
     // The `extend` property is an array, so we need to check if it contains any functions
     if (!isFunction(themeValue) && !some(extensions, isFunction)) {
-      return {
-        ...themeValue,
-        ...Object.assign({}, ...extensions),
-      }
+      return mergeWith({}, themeValue, ...extensions, mergeExtensionCustomizer)
     }
 
-    return (resolveThemePath, utils) => ({
-      ...value(themeValue, resolveThemePath, utils),
-      ...Object.assign({}, ...extensions.map((e) => value(e, resolveThemePath, utils))),
-    })
+    return (resolveThemePath, utils) =>
+      mergeWith(
+        {},
+        ...[themeValue, ...extensions].map((e) => value(e, resolveThemePath, utils)),
+        mergeExtensionCustomizer
+      )
   })
 }
 


Test patch:
diff --git a/__tests__/customConfig.test.js b/__tests__/customConfig.test.js
index 308cb873..0cc662c2 100644
--- a/__tests__/customConfig.test.js
+++ b/__tests__/customConfig.test.js
@@ -261,6 +261,56 @@ test('the default config can be overridden using the presets key', () => {
     })
 })
 
+test('presets can be functions', () => {
+  return postcss([
+    tailwind({
+      presets: [
+        () => ({
+          theme: {
+            extend: {
+              minHeight: {
+                24: '24px',
+              },
+            },
+          },
+          corePlugins: ['minHeight'],
+          variants: { minHeight: [] },
+        }),
+      ],
+      theme: {
+        extend: { minHeight: { 48: '48px' } },
+      },
+    }),
+  ])
+    .process(
+      `
+        @tailwind utilities
+      `,
+      { from: undefined }
+    )
+    .then((result) => {
+      const expected = `
+        .min-h-0 {
+          min-height: 0;
+        }
+        .min-h-24 {
+          min-height: 24px;
+        }
+        .min-h-48 {
+          min-height: 48px;
+        }
+        .min-h-full {
+          min-height: 100%;
+        }
+        .min-h-screen {
+          min-height: 100vh;
+        }
+      `
+
+      expect(result.css).toMatchCss(expected)
+    })
+})
+
 test('the default config can be removed by using an empty presets key in a preset', () => {
   return postcss([
     tailwind({
@@ -367,3 +417,68 @@ test('presets can have their own presets', () => {
       expect(result.css).toMatchCss(expected)
     })
 })
+
+test('function presets can be mixed with object presets', () => {
+  return postcss([
+    tailwind({
+      presets: [
+        () => ({
+          presets: [],
+          theme: {
+            colors: { red: '#dd0000' },
+          },
+        }),
+        {
+          presets: [
+            () => ({
+              presets: [],
+              theme: {
+                colors: {
+                  transparent: 'transparent',
+                  red: '#ff0000',
+                },
+              },
+            }),
+          ],
+          theme: {
+            extend: {
+              colors: {
+                black: 'black',
+                red: '#ee0000',
+              },
+              backgroundColor: (theme) => theme('colors'),
+            },
+          },
+          corePlugins: ['backgroundColor'],
+        },
+      ],
+      theme: {
+        extend: { colors: { white: 'white' } },
+      },
+    }),
+  ])
+    .process(
+      `
+        @tailwind utilities
+      `,
+      { from: undefined }
+    )
+    .then((result) => {
+      const expected = `
+        .bg-transparent {
+          background-color: transparent;
+        }
+        .bg-red {
+          background-color: #ee0000;
+        }
+        .bg-black {
+          background-color: black;
+        }
+        .bg-white {
+          background-color: white;
+        }
+      `
+
+      expect(result.css).toMatchCss(expected)
+    })
+})
diff --git a/__tests__/resolveConfig.test.js b/__tests__/resolveConfig.test.js
index 7295717a..e7a728d6 100644
--- a/__tests__/resolveConfig.test.js
+++ b/__tests__/resolveConfig.test.js
@@ -229,6 +229,55 @@ test('theme key is merged instead of replaced', () => {
   })
 })
 
+test('theme key is deeply merged instead of replaced', () => {
+  const userConfig = {
+    theme: {
+      extend: {
+        colors: {
+          grey: {
+            darker: '#606f7b',
+            dark: '#8795a1',
+          },
+        },
+      },
+    },
+  }
+
+  const defaultConfig = {
+    prefix: '-',
+    important: false,
+    separator: ':',
+    theme: {
+      colors: {
+        grey: {
+          grey: '#b8c2cc',
+          light: '#dae1e7',
+          lighter: '#f1f5f8',
+        },
+      },
+    },
+  }
+
+  const result = resolveConfig([userConfig, defaultConfig])
+
+  expect(result).toMatchObject({
+    prefix: '-',
+    important: false,
+    separator: ':',
+    theme: {
+      colors: {
+        grey: {
+          darker: '#606f7b',
+          dark: '#8795a1',
+          grey: '#b8c2cc',
+          light: '#dae1e7',
+          lighter: '#f1f5f8',
+        },
+      },
+    },
+  })
+})
+
 test('variants key is merged instead of replaced', () => {
   const userConfig = {
     variants: {
@@ -1779,6 +1828,70 @@ test('variants can be extended', () => {
   })
 })
 
+test('extensions are applied in the right order', () => {
+  const userConfig = {
+    theme: {
+      extend: {
+        colors: {
+          grey: {
+            light: '#eee',
+          },
+        },
+      },
+    },
+  }
+
+  const otherConfig = {
+    theme: {
+      extend: {
+        colors: {
+          grey: {
+            light: '#ddd',
+            darker: '#111',
+          },
+        },
+      },
+    },
+  }
+
+  const anotherConfig = {
+    theme: {
+      extend: {
+        colors: {
+          grey: {
+            darker: '#222',
+          },
+        },
+      },
+    },
+  }
+
+  const defaultConfig = {
+    theme: {
+      colors: {
+        grey: {
+          light: '#ccc',
+          dark: '#333',
+        },
+      },
+    },
+  }
+
+  const result = resolveConfig([userConfig, otherConfig, anotherConfig, defaultConfig])
+
+  expect(result).toMatchObject({
+    theme: {
+      colors: {
+        grey: {
+          light: '#eee',
+          dark: '#333',
+          darker: '#111',
+        },
+      },
+    },
+  })
+})
+
 test('variant sort order can be customized', () => {
   const userConfig = {
     variantOrder: [