Repository: tailwindlabs/tailwindcss
Pull request #2474 (2020-09-30 17:00:20)

Support new `presets` key + extending core plugins config
This PR adds support for adding a `presets` key to your Tailwind config to make it possible to ignore Tailwind's default config file.

This configuration is identical to Tailwind's default behavior, and this is what happens if the `presets` key is omitted:

```js
// tailwind.config.js
module.exports = {
  presets: [require('tailwindcss/defaultConfig')],
  theme: { ... }
}
```

This configuration removes Tailwind's default config from the list of configs to resolve, essentially disabling all defaults:

```js
// tailwind.config.js
module.exports = {
  presets: [],
  theme: { ... }
}
```

This configuration specifies a custom base config file, useful if you have a config you share across different properties at your company:

```js
// tailwind.config.js
module.exports = {
  presets: [require('@acmecorp/tailwind-config')],
  theme: { ... }
}
```

Multiple presets can be applied, and they will be resolved in order, with presets at the end taking precedence over presets at the beginning:

```js
// tailwind.config.js
module.exports = {
  presets: [
    require('@acmecorp/base-tailwind-config')
    require('@acmecorp/marketing-tailwind-config')
  ],
  theme: { ... }
}
```

Presets can also have their own presets, so the above configuration could be rewritten like this:

```js
// @acmecorp/marketing-tailwind-config
module.exports = {
  presets: [require('@acmecorp/base-tailwind-config')]
  // ...
}

// tailwind.config.js
module.exports = {
  presets: [
    require('@acmecorp/marketing-tailwind-config')
  ],
  theme: { ... }
}
```

This PR also adds better support for merging the `corePlugins` key, so now your custom default config can specify the core plugins you want to enable while still being able to extend it in a project specific config:

```js
// tailwind.config.
const sharedConfig = {
  // ...
  corePlugins: ['backgroundColor', 'float', 'opacity', 'padding']
}

module.exports = [
  sharedConfig,
  {
    // ...
    corePlugins: {
      float: false,
    }
  }
]
```

If you'd like to add core plugins to your base config, you can use a function syntax that receives the so-far-resolved `corePlugins` array as a destructurable argument:

```js
// tailwind.config.js
const sharedConfig = {
  // ...
  corePlugins: ['backgroundColor', 'float', 'opacity', 'padding']
}

module.exports = {
  // ...
  presets: [sharedConfig],
  corePlugins: ({ corePlugins }) => {
    return [...corePlugins, 'margin']
  }
}
```

We may add helpers like `after` and `before` in the future, to mimic the API that is available for variants.

This PR in general is in service of introducing a new "lite" config mode for Tailwind, for folks who want a more minimal starting point.

adamwathan: To do:  consider changing to a preset key instead of an array config, and make sure it works recursively.
adamwathan: Updated to drop the top level array format and instead use a `presets` key.
leodr: Since `tailwind.config.js` is already a JavaScript file, can't you just use the object spread syntax to extend other themes? And disable the default theme by supplying an empty object to the theme property?
adamwathan: Nope, the resolution logic is quite complex because of things like function values and the “extend” theme key. Check out the tests for resolveConfig 😳
mnaderian: I'm waiting for the day when Bootstrap is completely beaten. Then we can rule the world again!! Tailwind CSS should really rule the web cause it's really better than others and has better taste.

As all fathers say: Be patient son!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 116052da..57b59486 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - Add experimental `2xl` breakpoint ([#2468](https://github.com/tailwindlabs/tailwindcss/pull/2468))
 - Add `col-span-full` and `row-span-full` ([#2471](https://github.com/tailwindlabs/tailwindcss/pull/2471))
 - Promote `defaultLineHeights` and `standardFontWeights` from experimental to future
+- Add new `presets` config option ([#2474](https://github.com/tailwindlabs/tailwindcss/pull/2474))
 
 ## [1.8.12] - 2020-10-07
 
diff --git a/jest/runInTempDirectory.js b/jest/runInTempDirectory.js
index 02536104..c0928e21 100644
--- a/jest/runInTempDirectory.js
+++ b/jest/runInTempDirectory.js
@@ -3,9 +3,11 @@ import path from 'path'
 
 import rimraf from 'rimraf'
 
+let id = 0
+
 export default function(callback) {
   return new Promise(resolve => {
-    const workerId = process.env.JEST_WORKER_ID
+    const workerId = `${process.env.JEST_WORKER_ID}-${id++}`
     const tmpPath = path.resolve(__dirname, `../__tmp_${workerId}`)
     const currentPath = process.cwd()
 
diff --git a/resolveConfig.js b/resolveConfig.js
index a9db71f4..6fb5c15f 100644
--- a/resolveConfig.js
+++ b/resolveConfig.js
@@ -3,6 +3,5 @@ const getAllConfigs = require('./lib/util/getAllConfigs').default
 
 module.exports = function resolveConfig(...configs) {
   const [, ...defaultConfigs] = getAllConfigs(configs[0])
-
   return resolveConfigObjects([...configs, ...defaultConfigs])
 }
diff --git a/src/corePlugins.js b/src/corePlugins.js
index 61e7b217..63e17750 100644
--- a/src/corePlugins.js
+++ b/src/corePlugins.js
@@ -112,118 +112,122 @@ import animation from './plugins/animation'
 
 import configurePlugins from './util/configurePlugins'
 
+export const corePluginList = {
+  preflight,
+  container,
+  space,
+  divideWidth,
+  divideColor,
+  divideStyle,
+  divideOpacity,
+  accessibility,
+  appearance,
+  backgroundAttachment,
+  backgroundClip,
+  backgroundColor,
+  backgroundImage,
+  gradientColorStops,
+  backgroundOpacity,
+  backgroundPosition,
+  backgroundRepeat,
+  backgroundSize,
+  borderCollapse,
+  borderColor,
+  borderOpacity,
+  borderRadius,
+  borderStyle,
+  borderWidth,
+  boxSizing,
+  cursor,
+  display,
+  flexDirection,
+  flexWrap,
+  placeItems,
+  placeContent,
+  placeSelf,
+  alignItems,
+  alignContent,
+  alignSelf,
+  justifyItems,
+  justifyContent,
+  justifySelf,
+  flex,
+  flexGrow,
+  flexShrink,
+  order,
+  float,
+  clear,
+  fontFamily,
+  fontWeight,
+  height,
+  fontSize,
+  lineHeight,
+  listStylePosition,
+  listStyleType,
+  margin,
+  maxHeight,
+  maxWidth,
+  minHeight,
+  minWidth,
+  objectFit,
+  objectPosition,
+  opacity,
+  outline,
+  overflow,
+  overscrollBehavior,
+  padding,
+  placeholderColor,
+  placeholderOpacity,
+  pointerEvents,
+  position,
+  inset,
+  resize,
+  boxShadow,
+  fill,
+  stroke,
+  strokeWidth,
+  tableLayout,
+  textAlign,
+  textColor,
+  textOpacity,
+  fontStyle,
+  textTransform,
+  textDecoration,
+  fontSmoothing,
+  fontVariantNumeric,
+  letterSpacing,
+  userSelect,
+  verticalAlign,
+  visibility,
+  whitespace,
+  wordBreak,
+  width,
+  zIndex,
+  gap,
+  gridAutoFlow,
+  gridTemplateColumns,
+  gridColumn,
+  gridColumnStart,
+  gridColumnEnd,
+  gridTemplateRows,
+  gridRow,
+  gridRowStart,
+  gridRowEnd,
+  transform,
+  transformOrigin,
+  scale,
+  rotate,
+  translate,
+  skew,
+  transitionProperty,
+  transitionTimingFunction,
+  transitionDuration,
+  transitionDelay,
+  animation,
+}
+
 export default function({ corePlugins: corePluginConfig }) {
-  return configurePlugins(corePluginConfig, {
-    preflight,
-    container,
-    space,
-    divideWidth,
-    divideColor,
-    divideStyle,
-    divideOpacity,
-    accessibility,
-    appearance,
-    backgroundAttachment,
-    backgroundClip,
-    backgroundColor,
-    backgroundImage,
-    gradientColorStops,
-    backgroundOpacity,
-    backgroundPosition,
-    backgroundRepeat,
-    backgroundSize,
-    borderCollapse,
-    borderColor,
-    borderOpacity,
-    borderRadius,
-    borderStyle,
-    borderWidth,
-    boxSizing,
-    cursor,
-    display,
-    flexDirection,
-    flexWrap,
-    placeItems,
-    placeContent,
-    placeSelf,
-    alignItems,
-    alignContent,
-    alignSelf,
-    justifyItems,
-    justifyContent,
-    justifySelf,
-    flex,
-    flexGrow,
-    flexShrink,
-    order,
-    float,
-    clear,
-    fontFamily,
-    fontWeight,
-    height,
-    fontSize,
-    lineHeight,
-    listStylePosition,
-    listStyleType,
-    margin,
-    maxHeight,
-    maxWidth,
-    minHeight,
-    minWidth,
-    objectFit,
-    objectPosition,
-    opacity,
-    outline,
-    overflow,
-    overscrollBehavior,
-    padding,
-    placeholderColor,
-    placeholderOpacity,
-    pointerEvents,
-    position,
-    inset,
-    resize,
-    boxShadow,
-    fill,
-    stroke,
-    strokeWidth,
-    tableLayout,
-    textAlign,
-    textColor,
-    textOpacity,
-    fontStyle,
-    textTransform,
-    textDecoration,
-    fontSmoothing,
-    fontVariantNumeric,
-    letterSpacing,
-    userSelect,
-    verticalAlign,
-    visibility,
-    whitespace,
-    wordBreak,
-    width,
-    zIndex,
-    gap,
-    gridAutoFlow,
-    gridTemplateColumns,
-    gridColumn,
-    gridColumnStart,
-    gridColumnEnd,
-    gridTemplateRows,
-    gridRow,
-    gridRowStart,
-    gridRowEnd,
-    transform,
-    transformOrigin,
-    scale,
-    rotate,
-    translate,
-    skew,
-    transitionProperty,
-    transitionTimingFunction,
-    transitionDuration,
-    transitionDelay,
-    animation,
+  return configurePlugins(corePluginConfig, Object.keys(corePluginList)).map(pluginName => {
+    return corePluginList[pluginName]()
   })
 }
diff --git a/src/index.js b/src/index.js
index 4b34ee27..d2def911 100644
--- a/src/index.js
+++ b/src/index.js
@@ -45,7 +45,7 @@ function resolveConfigPath(filePath) {
 }
 
 const getConfigFunction = config => () => {
-  if (_.isUndefined(config) && !_.isObject(config)) {
+  if (_.isUndefined(config)) {
     return resolveConfig([...getAllConfigs(defaultConfig)])
   }
 
diff --git a/src/processTailwindFeatures.js b/src/processTailwindFeatures.js
index a93ad611..4d94b47d 100644
--- a/src/processTailwindFeatures.js
+++ b/src/processTailwindFeatures.js
@@ -37,7 +37,7 @@ export default function(getConfig) {
         [
           ...corePlugins(config),
           ...[flagEnabled(config, 'darkModeVariant') ? darkModeVariantPlugin : () => {}],
-          ...config.plugins,
+          ..._.get(config, 'plugins', []),
         ],
         config
       )
diff --git a/src/util/configurePlugins.js b/src/util/configurePlugins.js
index 295ae5bf..b44b04bd 100644
--- a/src/util/configurePlugins.js
+++ b/src/util/configurePlugins.js
@@ -1,9 +1,13 @@
 export default function(pluginConfig, plugins) {
+  if (pluginConfig === undefined) {
+    return plugins
+  }
+
   const pluginNames = Array.isArray(pluginConfig)
     ? pluginConfig
-    : Object.keys(plugins).filter(pluginName => {
+    : plugins.filter(pluginName => {
         return pluginConfig !== false && pluginConfig[pluginName] !== false
       })
 
-  return pluginNames.map(pluginName => plugins[pluginName]())
+  return pluginNames
 }
diff --git a/src/util/getAllConfigs.js b/src/util/getAllConfigs.js
index b48c003a..ef67e06c 100644
--- a/src/util/getAllConfigs.js
+++ b/src/util/getAllConfigs.js
@@ -7,9 +7,13 @@ import extendedFontSizeScale from '../flagged/extendedFontSizeScale.js'
 import darkModeVariant from '../flagged/darkModeVariant.js'
 import standardFontWeights from '../flagged/standardFontWeights'
 import additionalBreakpoint from '../flagged/additionalBreakpoint'
+import { flatMap, get } from 'lodash'
+
+export default function getAllConfigs(config, defaultPresets = [defaultConfig]) {
+  const configs = flatMap([...get(config, 'presets', defaultPresets)].reverse(), preset => {
+    return getAllConfigs(preset, [])
+  })
 
-export default function getAllConfigs(config) {
-  const configs = [defaultConfig]
   const features = {
     uniformColorPalette,
     extendedSpacingScale,
diff --git a/src/util/prefixSelector.js b/src/util/prefixSelector.js
index c4448e8a..7f9c9a95 100644
--- a/src/util/prefixSelector.js
+++ b/src/util/prefixSelector.js
@@ -2,7 +2,8 @@ import parser from 'postcss-selector-parser'
 import tap from 'lodash/tap'
 
 export default function(prefix, selector) {
-  const getPrefix = typeof prefix === 'function' ? prefix : () => prefix
+  const getPrefix =
+    typeof prefix === 'function' ? prefix : () => (prefix === undefined ? '' : prefix)
 
   return parser(selectors => {
     selectors.walkClasses(classSelector => {
diff --git a/src/util/processPlugins.js b/src/util/processPlugins.js
index d381e4c9..1b33d5f3 100644
--- a/src/util/processPlugins.js
+++ b/src/util/processPlugins.js
@@ -74,7 +74,7 @@ export default function(plugins, config) {
           return config.target === 'browserslist' ? browserslistTarget : config.target
         }
 
-        const [defaultTarget, targetOverrides] = getConfigValue('target')
+        const [defaultTarget, targetOverrides] = getConfigValue('target', 'relaxed')
 
         const target = _.get(targetOverrides, path, defaultTarget)
 
diff --git a/src/util/resolveConfig.js b/src/util/resolveConfig.js
index 0af2b176..961cc93a 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 toPath from 'lodash/toPath'
 import negateValue from './negateValue'
+import { corePluginList } from '../corePlugins'
+import configurePlugins from './configurePlugins'
 
 const configUtils = {
   negative(scale) {
@@ -187,6 +189,17 @@ function resolveVariants([firstConfig, ...variantConfigs]) {
   }, {})
 }
 
+function resolveCorePlugins(corePluginConfigs) {
+  const result = [...corePluginConfigs].reverse().reduce((resolved, corePluginConfig) => {
+    if (isFunction(corePluginConfig)) {
+      return corePluginConfig({ corePlugins: resolved })
+    }
+    return configurePlugins(corePluginConfig, resolved)
+  }, Object.keys(corePluginList))
+
+  return result
+}
+
 export default function resolveConfig(configs) {
   const allConfigs = extractPluginConfigs(configs)
 
@@ -196,6 +209,7 @@ export default function resolveConfig(configs) {
         mergeExtensions(mergeThemes(map(allConfigs, t => get(t, 'theme', {}))))
       ),
       variants: resolveVariants(allConfigs.map(c => c.variants)),
+      corePlugins: resolveCorePlugins(allConfigs.map(c => c.corePlugins)),
     },
     ...allConfigs
   )


Test patch:
diff --git a/__tests__/configurePlugins.test.js b/__tests__/configurePlugins.test.js
index 854686ef..03d9852a 100644
--- a/__tests__/configurePlugins.test.js
+++ b/__tests__/configurePlugins.test.js
@@ -1,11 +1,7 @@
 import configurePlugins from '../src/util/configurePlugins'
 
 test('setting a plugin to false removes it', () => {
-  const plugins = {
-    fontSize: () => 'fontSize',
-    display: () => 'display',
-    backgroundPosition: () => 'backgroundPosition',
-  }
+  const plugins = ['fontSize', 'display', 'backgroundPosition']
 
   const configuredPlugins = configurePlugins(
     {
@@ -18,11 +14,7 @@ test('setting a plugin to false removes it', () => {
 })
 
 test('passing only false removes all plugins', () => {
-  const plugins = {
-    fontSize: () => 'fontSize',
-    display: () => 'display',
-    backgroundPosition: () => 'backgroundPosition',
-  }
+  const plugins = ['fontSize', 'display', 'backgroundPosition']
 
   const configuredPlugins = configurePlugins(false, plugins)
 
@@ -30,11 +22,7 @@ test('passing only false removes all plugins', () => {
 })
 
 test('passing an array whitelists plugins', () => {
-  const plugins = {
-    fontSize: () => 'fontSize',
-    display: () => 'display',
-    backgroundPosition: () => 'backgroundPosition',
-  }
+  const plugins = ['fontSize', 'display', 'backgroundPosition']
 
   const configuredPlugins = configurePlugins(['display'], plugins)
 
diff --git a/__tests__/customConfig.test.js b/__tests__/customConfig.test.js
index 5c3f3e7d..ae00c0ba 100644
--- a/__tests__/customConfig.test.js
+++ b/__tests__/customConfig.test.js
@@ -210,3 +210,107 @@ test('tailwind.config.js is picked up by default when passing an empty object',
       })
   })
 })
+
+test('the default config can be overridden using the presets key', () => {
+  return postcss([
+    tailwind({
+      presets: [
+        {
+          theme: {
+            extend: {
+              colors: {
+                black: 'black',
+              },
+              backgroundColor: theme => theme('colors'),
+            },
+          },
+          corePlugins: ['backgroundColor'],
+        },
+      ],
+      theme: {
+        extend: { colors: { white: 'white' } },
+      },
+    }),
+  ])
+    .process(
+      `
+        @tailwind utilities
+      `,
+      { from: undefined }
+    )
+    .then(result => {
+      const expected = `
+        .bg-black {
+          background-color: black;
+        }
+        .bg-white {
+          background-color: white;
+        }
+      `
+
+      expect(result.css).toMatchCss(expected)
+    })
+})
+
+test('presets can have their own presets', () => {
+  return postcss([
+    tailwind({
+      presets: [
+        {
+          theme: {
+            colors: { red: '#dd0000' },
+          },
+        },
+        {
+          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 50e6b987..f2db50ea 100644
--- a/__tests__/resolveConfig.test.js
+++ b/__tests__/resolveConfig.test.js
@@ -1,3 +1,4 @@
+import { corePluginList } from '../src/corePlugins'
 import resolveConfig from '../src/util/resolveConfig'
 
 test('prefix key overrides default prefix', () => {
@@ -23,7 +24,7 @@ test('prefix key overrides default prefix', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: 'tw-',
     important: false,
     separator: ':',
@@ -63,7 +64,7 @@ test('important key overrides default important', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '',
     important: true,
     separator: ':',
@@ -103,7 +104,7 @@ test('important (selector) key overrides default important', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '',
     important: '#app',
     separator: ':',
@@ -143,7 +144,7 @@ test('separator key overrides default separator', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '',
     important: false,
     separator: '__',
@@ -200,7 +201,7 @@ test('theme key is merged instead of replaced', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -269,7 +270,7 @@ test('variants key is merged instead of replaced', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -339,7 +340,7 @@ test('a global variants list replaces the default', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -387,7 +388,7 @@ test('missing top level keys are pulled from the default config', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -437,7 +438,7 @@ test('functions in the default theme section are lazily evaluated', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -505,7 +506,7 @@ test('functions in the user theme section are lazily evaluated', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -575,7 +576,7 @@ test('theme values in the extend section extend the existing theme', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -659,7 +660,7 @@ test('theme values in the extend section extend the user theme', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -731,7 +732,7 @@ test('theme values in the extend section can extend values that are depended on
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -789,7 +790,7 @@ test('theme values in the extend section are not deeply merged', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -839,7 +840,7 @@ test('the theme function can use a default value if the key is missing', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -896,7 +897,7 @@ test('the theme function can resolve function values', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -964,7 +965,7 @@ test('the theme function can resolve deep function values', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -1029,7 +1030,7 @@ test('theme values in the extend section are lazily evaluated', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -1094,7 +1095,7 @@ test('lazily evaluated values have access to the config utils', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -1225,7 +1226,7 @@ test('custom properties are multiplied by -1 for negative values', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -1343,7 +1344,7 @@ test('more than two config objects can be resolved', () => {
 
   const result = resolveConfig([firstConfig, secondConfig, thirdConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '-',
     important: false,
     separator: ':',
@@ -1414,7 +1415,7 @@ test('plugin config modifications are applied', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: 'tw-',
     important: false,
     separator: ':',
@@ -1462,7 +1463,7 @@ test('user config takes precedence over plugin config modifications', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: 'user-',
     important: false,
     separator: ':',
@@ -1522,7 +1523,7 @@ test('plugin config can register plugins that also have config', () => {
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: 'tw-',
     important: true,
     separator: '__',
@@ -1577,7 +1578,7 @@ test('plugin configs take precedence over plugin configs registered by that plug
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: 'outer-',
     important: false,
     separator: ':',
@@ -1642,7 +1643,7 @@ test('plugin theme extensions are added even if user overrides top-level theme c
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '',
     important: false,
     separator: ':',
@@ -1713,7 +1714,7 @@ test('user theme extensions take precedence over plugin theme extensions with th
 
   const result = resolveConfig([userConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '',
     important: false,
     separator: ':',
@@ -1779,7 +1780,7 @@ test('variants can be defined as a function', () => {
 
   const result = resolveConfig([userConfig, otherConfig, defaultConfig])
 
-  expect(result).toEqual({
+  expect(result).toMatchObject({
     prefix: '',
     important: false,
     separator: ':',
@@ -1795,6 +1796,63 @@ test('variants can be defined as a function', () => {
       rotate: ['responsive', 'focus'],
       cursor: ['focus', 'checked', 'hover'],
     },
-    plugins: userConfig.plugins,
+  })
+})
+
+test('core plugin configuration builds on the default list when starting with an empty object', () => {
+  const userConfig = {
+    corePlugins: { display: false },
+  }
+
+  const defaultConfig = {
+    prefix: '',
+    important: false,
+    separator: ':',
+    theme: {},
+    variants: {},
+    corePlugins: {},
+  }
+
+  const result = resolveConfig([userConfig, defaultConfig])
+
+  expect(result).toMatchObject({
+    prefix: '',
+    important: false,
+    separator: ':',
+    theme: {},
+    variants: {},
+    corePlugins: Object.keys(corePluginList).filter(c => c !== 'display'),
+  })
+})
+
+test('core plugin configurations stack', () => {
+  const userConfig = {
+    corePlugins: { display: false },
+  }
+
+  const otherConfig = {
+    corePlugins: ({ corePlugins }) => {
+      return [...corePlugins, 'margin']
+    },
+  }
+
+  const defaultConfig = {
+    prefix: '',
+    important: false,
+    separator: ':',
+    theme: {},
+    variants: {},
+    corePlugins: ['float', 'display', 'padding'],
+  }
+
+  const result = resolveConfig([userConfig, otherConfig, defaultConfig])
+
+  expect(result).toMatchObject({
+    prefix: '',
+    important: false,
+    separator: ':',
+    theme: {},
+    variants: {},
+    corePlugins: ['float', 'padding', 'margin'],
   })
 })