Skip to content

Commit 8c6c291

Browse files
Make config resolution lazy (#14362)
The internal `registerPlugins()` API is used to enable backwards compatibility with v3 plugins and configs and it is called on every build even when no v3 plugins or configs are used. This function has a non-trivial cost in that case — around 5ms. So this PR does a few things: ## Implements a simpler, faster `theme(…)` function We now have a much simpler `theme(…)` function that can be used when backwards compatibility is not necessary. It still supports many of the same features: - The modern, v4 style CSS variable syntax `theme(--color-red-500)` - The legacy, v3 path style `theme(colors.red.500)` - And the v3-style alpha modifier `theme(colors.red.500 / 50%)` - Path upgrades so things like `theme(accentColor.red.500)` pulls from `--color-red-500` when no `--accent-color-red-500` theme key exists When you do have plugins or configs the more advanced `theme(…)` function is swapped in for more complete backwards compatibility. ## `registerPlugins` registers globs Before `registerPlugins` passed the `ResolvedConfig` out so we could register globs in `compile()`. Since that one function is really the main driver for backwards compat we decided to move the content path registration into `registerPlugins` itself when it comes to paths provided by plugins and configs. This is an internal implementation detail (well this entire PR is) but it's worth mentioning. This method is used to resolve a theme value from a theme key. ## `registerPlugins` is now only called when necessary All of the above work made it so that `registerPlugins` can be called only as needed. This means that when no v3 plugins or configs are used, `registerPlugins` is never called thus elminating the performance impact of config resolution. --------- Co-authored-by: Adam Wathan <[email protected]>
1 parent 783b323 commit 8c6c291

File tree

7 files changed

+195
-32
lines changed

7 files changed

+195
-32
lines changed

packages/tailwindcss/src/compat/plugin-functions.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export function createThemeFn(
1111
configTheme: () => UserConfig['theme'],
1212
resolveValue: (value: any) => any,
1313
) {
14-
return function theme(path: string, defaultValue?: any) {
14+
return function theme(path: string, defaultValue?: unknown) {
1515
// Extract an eventual modifier from the path. e.g.:
1616
// - "colors.red.500 / 50%" -> "50%"
1717
// - "foo/bar/baz/50%" -> "50%"
@@ -148,7 +148,7 @@ function readFromCss(
148148
() => new Map(),
149149
)
150150

151-
let ns = theme.namespace(`--${themeKey}` as any)
151+
let ns = theme.namespace(`--${themeKey}`)
152152
if (ns.size === 0) {
153153
return [null, ThemeOptions.NONE]
154154
}

packages/tailwindcss/src/css-functions.test.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,9 +288,13 @@ describe('theme function', () => {
288288
`)
289289
})
290290

291-
test('theme(fontFamily.sans)', async () => {
291+
test('theme(fontFamily.sans) (css)', async () => {
292292
expect(
293293
await compileCss(css`
294+
@theme default reference {
295+
--font-family-sans: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji,
296+
Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
297+
}
294298
.fam {
295299
font-family: theme(fontFamily.sans);
296300
}
@@ -301,6 +305,26 @@ describe('theme function', () => {
301305
}"
302306
`)
303307
})
308+
309+
test('theme(fontFamily.sans) (config)', async () => {
310+
let compiled = await compile(
311+
css`
312+
@config "./my-config.js";
313+
.fam {
314+
font-family: theme(fontFamily.sans);
315+
}
316+
`,
317+
{
318+
loadConfig: async () => ({}),
319+
},
320+
)
321+
322+
expect(optimizeCss(compiled.build([])).trim()).toMatchInlineSnapshot(`
323+
".fam {
324+
font-family: ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
325+
}"
326+
`)
327+
})
304328
})
305329

306330
test('theme(colors.unknown.500)', async () =>

packages/tailwindcss/src/css-functions.ts

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
import { walk, type AstNode } from './ast'
2-
import type { PluginAPI } from './plugin-api'
32
import * as ValueParser from './value-parser'
43
import { type ValueAstNode } from './value-parser'
54

65
export const THEME_FUNCTION_INVOCATION = 'theme('
76

8-
export function substituteFunctions(ast: AstNode[], pluginApi: PluginAPI) {
7+
type ResolveThemeValue = (path: string) => unknown
8+
9+
export function substituteFunctions(ast: AstNode[], resolveThemeValue: ResolveThemeValue) {
910
walk(ast, (node) => {
1011
// Find all declaration values
1112
if (node.kind === 'declaration' && node.value?.includes(THEME_FUNCTION_INVOCATION)) {
12-
node.value = substituteFunctionsInValue(node.value, pluginApi)
13+
node.value = substituteFunctionsInValue(node.value, resolveThemeValue)
1314
return
1415
}
1516

@@ -23,13 +24,16 @@ export function substituteFunctions(ast: AstNode[], pluginApi: PluginAPI) {
2324
node.selector.startsWith('@supports ')) &&
2425
node.selector.includes(THEME_FUNCTION_INVOCATION)
2526
) {
26-
node.selector = substituteFunctionsInValue(node.selector, pluginApi)
27+
node.selector = substituteFunctionsInValue(node.selector, resolveThemeValue)
2728
}
2829
}
2930
})
3031
}
3132

32-
export function substituteFunctionsInValue(value: string, pluginApi: PluginAPI): string {
33+
export function substituteFunctionsInValue(
34+
value: string,
35+
resolveThemeValue: ResolveThemeValue,
36+
): string {
3337
let ast = ValueParser.parse(value)
3438
ValueParser.walk(ast, (node, { replaceWith }) => {
3539
if (node.kind === 'function' && node.value === 'theme') {
@@ -67,26 +71,25 @@ export function substituteFunctionsInValue(value: string, pluginApi: PluginAPI):
6771
path = eventuallyUnquote(path)
6872
let fallbackValues = node.nodes.slice(skipUntilIndex + 1)
6973

70-
replaceWith(cssThemeFn(pluginApi, path, fallbackValues))
74+
replaceWith(cssThemeFn(resolveThemeValue, path, fallbackValues))
7175
}
7276
})
7377

7478
return ValueParser.toCss(ast)
7579
}
7680

7781
function cssThemeFn(
78-
pluginApi: PluginAPI,
82+
resolveThemeValue: ResolveThemeValue,
7983
path: string,
8084
fallbackValues: ValueAstNode[],
8185
): ValueAstNode[] {
8286
let resolvedValue: string | null = null
83-
let themeValue = pluginApi.theme(path)
87+
let themeValue = resolveThemeValue(path)
8488

85-
let isArray = Array.isArray(themeValue)
86-
if (isArray && themeValue.length === 2) {
89+
if (Array.isArray(themeValue) && themeValue.length === 2) {
8790
// When a tuple is returned, return the first element
8891
resolvedValue = themeValue[0]
89-
} else if (isArray) {
92+
} else if (Array.isArray(themeValue)) {
9093
// Arrays get serialized into a comma-separated lists
9194
resolvedValue = themeValue.join(', ')
9295
} else if (typeof themeValue === 'string') {

packages/tailwindcss/src/design-system.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { compileAstNodes, compileCandidates } from './compile'
44
import { getClassList, getVariants, type ClassEntry, type VariantEntry } from './intellisense'
55
import { getClassOrder } from './sort'
66
import type { Theme } from './theme'
7+
import { resolveThemeValue } from './theme-fn'
78
import { Utilities, createUtilities } from './utilities'
89
import { DefaultMap } from './utils/default-map'
910
import { Variants, createVariants } from './variants'
@@ -23,6 +24,7 @@ export type DesignSystem = {
2324
compileAstNodes(candidate: Candidate): ReturnType<typeof compileAstNodes>
2425

2526
getUsedVariants(): ReturnType<typeof parseVariant>[]
27+
resolveThemeValue(path: string, defaultValue?: string): string | undefined
2628
}
2729

2830
export function buildDesignSystem(theme: Theme): DesignSystem {
@@ -79,6 +81,10 @@ export function buildDesignSystem(theme: Theme): DesignSystem {
7981
getUsedVariants() {
8082
return Array.from(parsedVariants.values())
8183
},
84+
85+
resolveThemeValue(path: string, defaultValue?: string) {
86+
return resolveThemeValue(theme, path, defaultValue)
87+
},
8288
}
8389

8490
return designSystem

packages/tailwindcss/src/index.ts

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,9 @@ async function parseCss(
321321
})),
322322
)
323323

324-
let { pluginApi, resolvedConfig } = registerPlugins(plugins, designSystem, ast, configs)
324+
if (plugins.length || configs.length) {
325+
registerPlugins(plugins, designSystem, ast, configs, globs)
326+
}
325327

326328
for (let customVariant of customVariants) {
327329
customVariant(designSystem)
@@ -379,7 +381,7 @@ async function parseCss(
379381
// also contain functions or plugins that use functions so we need to evaluate
380382
// functions if either of those are present.
381383
if (plugins.length > 0 || configs.length > 0 || css.includes(THEME_FUNCTION_INVOCATION)) {
382-
substituteFunctions(ast, pluginApi)
384+
substituteFunctions(ast, designSystem.resolveThemeValue)
383385
}
384386

385387
// Remove `@utility`, we couldn't replace it before yet because we had to
@@ -396,19 +398,8 @@ async function parseCss(
396398
return WalkAction.Skip
397399
})
398400

399-
for (let file of resolvedConfig.content.files) {
400-
if ('raw' in file) {
401-
throw new Error(
402-
`Error in the config file/plugin/preset. The \`content\` key contains a \`raw\` entry:\n\n${JSON.stringify(file, null, 2)}\n\nThis feature is not currently supported.`,
403-
)
404-
}
405-
406-
globs.push({ origin: file.base, pattern: file.pattern })
407-
}
408-
409401
return {
410402
designSystem,
411-
pluginApi,
412403
ast,
413404
globs,
414405
}
@@ -421,7 +412,7 @@ export async function compile(
421412
globs: { origin?: string; pattern: string }[]
422413
build(candidates: string[]): string
423414
}> {
424-
let { designSystem, ast, globs, pluginApi } = await parseCss(css, opts)
415+
let { designSystem, ast, globs } = await parseCss(css, opts)
425416

426417
let tailwindUtilitiesNode: Rule | null = null
427418

@@ -491,7 +482,7 @@ export async function compile(
491482
// properties (`[--my-var:theme(--color-red-500)]`) can contain function
492483
// calls so we need evaluate any functions we find there that weren't in
493484
// the source CSS.
494-
substituteFunctions(newNodes, pluginApi)
485+
substituteFunctions(newNodes, designSystem.resolveThemeValue)
495486

496487
previousAstNodeCount = newNodes.length
497488

packages/tailwindcss/src/plugin-api.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,7 @@ export function registerPlugins(
360360
designSystem: DesignSystem,
361361
ast: AstNode[],
362362
configs: ConfigFile[],
363+
globs: { origin?: string; pattern: string }[],
363364
) {
364365
let plugins = pluginDetails.map((detail) => {
365366
if (!detail.options) {
@@ -392,8 +393,17 @@ export function registerPlugins(
392393
// core utilities already read from.
393394
applyConfigToTheme(designSystem, userConfig)
394395

395-
return {
396-
pluginApi,
397-
resolvedConfig,
396+
designSystem.resolveThemeValue = function resolveThemeValue(path: string, defaultValue?: string) {
397+
return pluginApi.theme(path, defaultValue)
398+
}
399+
400+
for (let file of resolvedConfig.content.files) {
401+
if ('raw' in file) {
402+
throw new Error(
403+
`Error in the config file/plugin/preset. The \`content\` key contains a \`raw\` entry:\n\n${JSON.stringify(file, null, 2)}\n\nThis feature is not currently supported.`,
404+
)
405+
}
406+
407+
globs.push({ origin: file.base, pattern: file.pattern })
398408
}
399409
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import type { Theme, ThemeKey } from './theme'
2+
import { withAlpha } from './utilities'
3+
import { toKeyPath } from './utils/to-key-path'
4+
5+
/**
6+
* Looks up a value in the CSS theme
7+
*/
8+
export function resolveThemeValue(theme: Theme, path: string, defaultValue?: string) {
9+
// Extract an eventual modifier from the path. e.g.:
10+
// - "colors.red.500 / 50%" -> "50%"
11+
// - "foo/bar/baz/50%" -> "50%"
12+
let lastSlash = path.lastIndexOf('/')
13+
let modifier: string | null = null
14+
if (lastSlash !== -1) {
15+
modifier = path.slice(lastSlash + 1).trim()
16+
path = path.slice(0, lastSlash).trim()
17+
}
18+
19+
let themeValue = lookupThemeValue(theme, path, defaultValue)
20+
21+
// Apply the opacity modifier if present
22+
if (modifier && typeof themeValue === 'string') {
23+
return withAlpha(themeValue, modifier)
24+
}
25+
26+
return themeValue
27+
}
28+
29+
function toThemeKey(keypath: string[]) {
30+
return (
31+
keypath
32+
// [1] should move into the nested object tuple. To create the CSS variable
33+
// name for this, we replace it with an empty string that will result in two
34+
// subsequent dashes when joined.
35+
.map((path) => (path === '1' ? '' : path))
36+
37+
// Resolve the key path to a CSS variable segment
38+
.map((part) =>
39+
part
40+
.replaceAll('.', '_')
41+
.replace(/([a-z])([A-Z])/g, (_, a, b) => `${a}-${b.toLowerCase()}`),
42+
)
43+
44+
// Remove the `DEFAULT` key at the end of a path
45+
// We're reading from CSS anyway so it'll be a string
46+
.filter((part, index) => part !== 'DEFAULT' || index !== keypath.length - 1)
47+
.join('-')
48+
)
49+
}
50+
51+
function lookupThemeValue(theme: Theme, path: string, defaultValue?: string) {
52+
if (path.startsWith('--')) {
53+
return theme.get([path as any]) ?? defaultValue
54+
}
55+
56+
let baseThemeKey = '--' + toThemeKey(toKeyPath(path))
57+
58+
let resolvedValue = theme.get([baseThemeKey as ThemeKey])
59+
60+
if (resolvedValue !== null) {
61+
return resolvedValue
62+
}
63+
64+
for (let [givenKey, upgradeKey] of Object.entries(themeUpgradeKeys)) {
65+
if (!baseThemeKey.startsWith(givenKey)) continue
66+
67+
let upgradedKey = upgradeKey + baseThemeKey.slice(givenKey.length)
68+
let resolvedValue = theme.get([upgradedKey as ThemeKey])
69+
70+
if (resolvedValue !== null) {
71+
return resolvedValue
72+
}
73+
}
74+
75+
return defaultValue
76+
}
77+
78+
let themeUpgradeKeys = {
79+
'--colors': '--color',
80+
'--accent-color': '--color',
81+
'--backdrop-blur': '--blur',
82+
'--backdrop-brightness': '--brightness',
83+
'--backdrop-contrast': '--contrast',
84+
'--backdrop-grayscale': '--grayscale',
85+
'--backdrop-hue-rotate': '--hueRotate',
86+
'--backdrop-invert': '--invert',
87+
'--backdrop-opacity': '--opacity',
88+
'--backdrop-saturate': '--saturate',
89+
'--backdrop-sepia': '--sepia',
90+
'--background-color': '--color',
91+
'--background-opacity': '--opacity',
92+
'--border-color': '--color',
93+
'--border-opacity': '--opacity',
94+
'--border-spacing': '--spacing',
95+
'--box-shadow-color': '--color',
96+
'--caret-color': '--color',
97+
'--divide-color': '--borderColor',
98+
'--divide-opacity': '--borderOpacity',
99+
'--divide-width': '--borderWidth',
100+
'--fill': '--color',
101+
'--flex-basis': '--spacing',
102+
'--gap': '--spacing',
103+
'--gradient-color-stops': '--color',
104+
'--height': '--spacing',
105+
'--inset': '--spacing',
106+
'--margin': '--spacing',
107+
'--max-height': '--spacing',
108+
'--max-width': '--spacing',
109+
'--min-height': '--spacing',
110+
'--min-width': '--spacing',
111+
'--outline-color': '--color',
112+
'--padding': '--spacing',
113+
'--placeholder-color': '--color',
114+
'--placeholder-opacity': '--opacity',
115+
'--ring-color': '--color',
116+
'--ring-offset-color': '--color',
117+
'--ring-opacity': '--opacity',
118+
'--scroll-margin': '--spacing',
119+
'--scroll-padding': '--spacing',
120+
'--space': '--spacing',
121+
'--stroke': '--color',
122+
'--text-color': '--color',
123+
'--text-decoration-color': '--color',
124+
'--text-indent': '--spacing',
125+
'--text-opacity': '--opacity',
126+
'--translate': '--spacing',
127+
'--size': '--spacing',
128+
'--width': '--spacing',
129+
}

0 commit comments

Comments
 (0)