Skip to content

Commit d6a67be

Browse files
authored
Add default option to @theme to support overriding default theme values from plugins/JS config files (#14327)
This PR adds a new `default` option to `@theme` to make it possible for plugins/JS config files to override default theme values, and also ensures that the final set of CSS variables we output takes into account any theme values added by plugins/JS config files. --- Previously, if you were using the default theme but also had a JS config file that overrode any of those defaults like this: ```js // ./tailwind.config.js export default { theme: { extend: { colors: { red: { '500': 'tomato', }, } } } } ``` …then utilities like `text-red-500` would correctly use `color: tomato`, but the `--color-red-500` CSS variable would still be set to the default value: ```css :root { --color-red-500: #ef4444; } ``` This feels like a straight-up bug — if `#ef4444` is not part of your design system because you've overridden it, it shouldn't show up in your set of CSS variables anywhere. So this PR fixes this issue by making sure we don't print the final set of CSS variables until all of your plugins and config files have had a chance to update the theme. --- The second issue is that we realized people have different expectations about how plugin/config theme values should interact with Tailwind's _default_ theme vs. explicitly user-configured theme values. Take this setup for instance: ```css @import "tailwindcss"; @config "./tailwind.config.js"; ``` If `tailwind.config.js` overrides `red-500` to be `tomato`, you'd expect `text-red-500` to actually be `tomato`, not the default `#ef4444` color. But in this setup: ```css @import "tailwindcss"; @config "./tailwind.config.js"; @theme { --color-red-500: #f00; } ``` …you'd expect `text-red-500` to be `#f00`. This is despite the fact that currently in Tailwind there is no difference here — they are both just `@theme` blocks, one just happens to be coming from an imported file (`@import "tailwindcss"`). So to resolve this ambiguity, I've added a `default` option to `@theme` for explicitly registering theme values as "defaults" that are safe to override with plugin/JS config theme values: ```css @import "tailwindcss"; @config "./tailwind.config.js"; @theme default { --color-red-500: #f00; } ``` Now `text-red-500` would be `tomato` here as per the config file. This API is not something users are generally going to interact with — they will almost never want to use `default` explicitly. But in this PR I've updated the default theme we ship with to include `default` so that it interacts in a more intuitive way with plugins and JS config files. --- Finally, this PR makes sure all theme values registered by plugins/configs are registered with `isReference: true` to make sure they do not end up in the final CSS at all. This is important to make sure that the super weird shit we used to do in configs in v3 doesn't get translated into nonsense variables that pollute your output (hello typography plugin I'm looking at you). If we don't do this, you'll end up with CSS variables like this: ```css :root { --typography-sm-css-blockquote-padding-inline-start: 1.25em; } ``` Preventing theme values registered in plugins/configs from outputting CSS values also serves the secondary purpose of nudging users to migrate to the CSS config if they do want CSS variables for their theme values. --------- Co-authored-by: Adam Wathan <[email protected]>
1 parent 6d0371a commit d6a67be

File tree

6 files changed

+264
-37
lines changed

6 files changed

+264
-37
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- Support TypeScript for `@plugin` and `@config` files ([#14317](https://github.com/tailwindlabs/tailwindcss/pull/14317))
13+
- Add `default` option to `@theme` to support overriding default theme values from plugins/JS config files ([#14327](https://github.com/tailwindlabs/tailwindcss/pull/14327))
1314

1415
### Fixed
1516

1617
- Ensure content globs defined in `@config` files are relative to that file ([#14314](https://github.com/tailwindlabs/tailwindcss/pull/14314))
1718
- Ensure CSS `theme()` functions are evaluated in media query ranges with collapsed whitespace ((#14321)[https://github.com/tailwindlabs/tailwindcss/pull/14321])
1819
- Fix support for Nuxt projects in the Vite plugin (requires Nuxt 3.13.1+) ([#14319](https://github.com/tailwindlabs/tailwindcss/pull/14319))
1920
- Evaluate theme functions in plugins and JS config files ([#14326](https://github.com/tailwindlabs/tailwindcss/pull/14326))
21+
- Ensure theme values overridden with `reference` values don't generate stale CSS variables ([#14327](https://github.com/tailwindlabs/tailwindcss/pull/14327))
2022

2123
## [4.0.0-alpha.21] - 2024-09-02
2224

packages/tailwindcss/src/compat/apply-config-to-theme.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ export function applyConfigToTheme(designSystem: DesignSystem, configs: ConfigFi
1010

1111
designSystem.theme.add(`--${name}`, value as any, {
1212
isInline: true,
13-
isReference: false,
13+
isReference: true,
14+
isDefault: true,
1415
})
1516
}
1617

packages/tailwindcss/src/index.test.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1292,6 +1292,215 @@ describe('Parsing themes values from CSS', () => {
12921292
}"
12931293
`)
12941294
})
1295+
1296+
test('`default` theme values can be overridden by regular theme values`', async () => {
1297+
expect(
1298+
await compileCss(
1299+
css`
1300+
@theme {
1301+
--color-potato: #ac855b;
1302+
}
1303+
@theme default {
1304+
--color-potato: #efb46b;
1305+
}
1306+
1307+
@tailwind utilities;
1308+
`,
1309+
['bg-potato'],
1310+
),
1311+
).toMatchInlineSnapshot(`
1312+
":root {
1313+
--color-potato: #ac855b;
1314+
}
1315+
1316+
.bg-potato {
1317+
background-color: var(--color-potato, #ac855b);
1318+
}"
1319+
`)
1320+
})
1321+
1322+
test('`default` and `inline` can be used together', async () => {
1323+
expect(
1324+
await compileCss(
1325+
css`
1326+
@theme default inline {
1327+
--color-potato: #efb46b;
1328+
}
1329+
1330+
@tailwind utilities;
1331+
`,
1332+
['bg-potato'],
1333+
),
1334+
).toMatchInlineSnapshot(`
1335+
":root {
1336+
--color-potato: #efb46b;
1337+
}
1338+
1339+
.bg-potato {
1340+
background-color: #efb46b;
1341+
}"
1342+
`)
1343+
})
1344+
1345+
test('`default` and `reference` can be used together', async () => {
1346+
expect(
1347+
await compileCss(
1348+
css`
1349+
@theme default reference {
1350+
--color-potato: #efb46b;
1351+
}
1352+
1353+
@tailwind utilities;
1354+
`,
1355+
['bg-potato'],
1356+
),
1357+
).toMatchInlineSnapshot(`
1358+
".bg-potato {
1359+
background-color: var(--color-potato, #efb46b);
1360+
}"
1361+
`)
1362+
})
1363+
1364+
test('`default`, `inline`, and `reference` can be used together', async () => {
1365+
expect(
1366+
await compileCss(
1367+
css`
1368+
@theme default reference inline {
1369+
--color-potato: #efb46b;
1370+
}
1371+
1372+
@tailwind utilities;
1373+
`,
1374+
['bg-potato'],
1375+
),
1376+
).toMatchInlineSnapshot(`
1377+
".bg-potato {
1378+
background-color: #efb46b;
1379+
}"
1380+
`)
1381+
})
1382+
1383+
test('`default` can be used in `media(…)`', async () => {
1384+
expect(
1385+
await compileCss(
1386+
css`
1387+
@media theme() {
1388+
@theme {
1389+
--color-potato: #ac855b;
1390+
}
1391+
}
1392+
@media theme(default) {
1393+
@theme {
1394+
--color-potato: #efb46b;
1395+
--color-tomato: tomato;
1396+
}
1397+
}
1398+
1399+
@tailwind utilities;
1400+
`,
1401+
['bg-potato', 'bg-tomato'],
1402+
),
1403+
).toMatchInlineSnapshot(`
1404+
":root {
1405+
--color-potato: #ac855b;
1406+
--color-tomato: tomato;
1407+
}
1408+
1409+
.bg-potato {
1410+
background-color: var(--color-potato, #ac855b);
1411+
}
1412+
1413+
.bg-tomato {
1414+
background-color: var(--color-tomato, tomato);
1415+
}"
1416+
`)
1417+
})
1418+
1419+
test('`default` theme values can be overridden by plugin theme values', async () => {
1420+
let { build } = await compile(
1421+
css`
1422+
@theme default {
1423+
--color-red: red;
1424+
}
1425+
@theme {
1426+
--color-orange: orange;
1427+
}
1428+
@plugin "my-plugin";
1429+
@tailwind utilities;
1430+
`,
1431+
{
1432+
loadPlugin: async () => {
1433+
return plugin(({}) => {}, {
1434+
theme: {
1435+
extend: {
1436+
colors: {
1437+
red: 'tomato',
1438+
orange: '#f28500',
1439+
},
1440+
},
1441+
},
1442+
})
1443+
},
1444+
},
1445+
)
1446+
1447+
expect(optimizeCss(build(['text-red', 'text-orange'])).trim()).toMatchInlineSnapshot(`
1448+
":root {
1449+
--color-orange: orange;
1450+
}
1451+
1452+
.text-orange {
1453+
color: var(--color-orange, orange);
1454+
}
1455+
1456+
.text-red {
1457+
color: tomato;
1458+
}"
1459+
`)
1460+
})
1461+
1462+
test('`default` theme values can be overridden by config theme values', async () => {
1463+
let { build } = await compile(
1464+
css`
1465+
@theme default {
1466+
--color-red: red;
1467+
}
1468+
@theme {
1469+
--color-orange: orange;
1470+
}
1471+
@config "./my-config.js";
1472+
@tailwind utilities;
1473+
`,
1474+
{
1475+
loadConfig: async () => {
1476+
return {
1477+
theme: {
1478+
extend: {
1479+
colors: {
1480+
red: 'tomato',
1481+
orange: '#f28500',
1482+
},
1483+
},
1484+
},
1485+
}
1486+
},
1487+
},
1488+
)
1489+
1490+
expect(optimizeCss(build(['text-red', 'text-orange'])).trim()).toMatchInlineSnapshot(`
1491+
":root {
1492+
--color-orange: orange;
1493+
}
1494+
1495+
.text-orange {
1496+
color: var(--color-orange, orange);
1497+
}
1498+
1499+
.text-red {
1500+
color: tomato;
1501+
}"
1502+
`)
1503+
})
12951504
})
12961505

12971506
describe('plugins', () => {

packages/tailwindcss/src/index.ts

Lines changed: 35 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,19 @@ function throwOnConfig(): never {
2828
function parseThemeOptions(selector: string) {
2929
let isReference = false
3030
let isInline = false
31+
let isDefault = false
3132

3233
for (let option of segment(selector.slice(6) /* '@theme'.length */, ' ')) {
3334
if (option === 'reference') {
3435
isReference = true
3536
} else if (option === 'inline') {
3637
isInline = true
38+
} else if (option === 'default') {
39+
isDefault = true
3740
}
3841
}
3942

40-
return { isReference, isInline }
43+
return { isReference, isInline, isDefault }
4144
}
4245

4346
async function parseCss(
@@ -253,8 +256,8 @@ async function parseCss(
253256
'Files imported with `@import "…" theme(…)` must only contain `@theme` blocks.',
254257
)
255258
}
256-
if (child.selector === '@theme') {
257-
child.selector = '@theme ' + themeParams
259+
if (child.selector === '@theme' || child.selector.startsWith('@theme ')) {
260+
child.selector += ' ' + themeParams
258261
return WalkAction.Skip
259262
}
260263
})
@@ -264,7 +267,7 @@ async function parseCss(
264267

265268
if (node.selector !== '@theme' && !node.selector.startsWith('@theme ')) return
266269

267-
let { isReference, isInline } = parseThemeOptions(node.selector)
270+
let { isReference, isInline, isDefault } = parseThemeOptions(node.selector)
268271

269272
// Record all custom properties in the `@theme` declaration
270273
walk(node.nodes, (child, { replaceWith }) => {
@@ -278,7 +281,7 @@ async function parseCss(
278281

279282
if (child.kind === 'comment') return
280283
if (child.kind === 'declaration' && child.property.startsWith('--')) {
281-
theme.add(child.property, child.value ?? '', { isReference, isInline })
284+
theme.add(child.property, child.value ?? '', { isReference, isInline, isDefault })
282285
return
283286
}
284287

@@ -302,6 +305,33 @@ async function parseCss(
302305
return WalkAction.Skip
303306
})
304307

308+
let designSystem = buildDesignSystem(theme)
309+
310+
let configs = await Promise.all(
311+
configPaths.map(async (configPath) => ({
312+
path: configPath,
313+
config: await loadConfig(configPath),
314+
})),
315+
)
316+
317+
let plugins = await Promise.all(
318+
pluginPaths.map(async ([pluginPath, pluginOptions]) => ({
319+
path: pluginPath,
320+
plugin: await loadPlugin(pluginPath),
321+
options: pluginOptions,
322+
})),
323+
)
324+
325+
let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs)
326+
327+
for (let customVariant of customVariants) {
328+
customVariant(designSystem)
329+
}
330+
331+
for (let customUtility of customUtilities) {
332+
customUtility(designSystem)
333+
}
334+
305335
// Output final set of theme variables at the position of the first `@theme`
306336
// rule.
307337
if (firstThemeRule) {
@@ -340,33 +370,6 @@ async function parseCss(
340370
firstThemeRule.nodes = nodes
341371
}
342372

343-
let designSystem = buildDesignSystem(theme)
344-
345-
let configs = await Promise.all(
346-
configPaths.map(async (configPath) => ({
347-
path: configPath,
348-
config: await loadConfig(configPath),
349-
})),
350-
)
351-
352-
let plugins = await Promise.all(
353-
pluginPaths.map(async ([pluginPath, pluginOptions]) => ({
354-
path: pluginPath,
355-
plugin: await loadPlugin(pluginPath),
356-
options: pluginOptions,
357-
})),
358-
)
359-
360-
let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs)
361-
362-
for (let customVariant of customVariants) {
363-
customVariant(designSystem)
364-
}
365-
366-
for (let customUtility of customUtilities) {
367-
customUtility(designSystem)
368-
}
369-
370373
// Replace `@apply` rules with the actual utility classes.
371374
if (css.includes('@apply')) {
372375
substituteAtApply(ast, designSystem)

packages/tailwindcss/src/theme.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import { escape } from './utils/escape'
22

33
export class Theme {
44
constructor(
5-
private values = new Map<string, { value: string; isReference: boolean; isInline: boolean }>(),
5+
private values = new Map<
6+
string,
7+
{ value: string; isReference: boolean; isInline: boolean; isDefault: boolean }
8+
>(),
69
) {}
710

8-
add(key: string, value: string, { isReference = false, isInline = false } = {}): void {
11+
add(
12+
key: string,
13+
value: string,
14+
{ isReference = false, isInline = false, isDefault = false } = {},
15+
): void {
916
if (key.endsWith('-*')) {
1017
if (value !== 'initial') {
1118
throw new Error(`Invalid theme value \`${value}\` for namespace \`${key}\``)
@@ -17,10 +24,15 @@ export class Theme {
1724
}
1825
}
1926

27+
if (isDefault) {
28+
let existing = this.values.get(key)
29+
if (existing && !existing.isDefault) return
30+
}
31+
2032
if (value === 'initial') {
2133
this.values.delete(key)
2234
} else {
23-
this.values.set(key, { value, isReference, isInline })
35+
this.values.set(key, { value, isReference, isInline, isDefault })
2436
}
2537
}
2638

0 commit comments

Comments
 (0)