Skip to content

Commit dc7b00a

Browse files
committed
Invalidate context when CSS changes
1 parent 7a24b3f commit dc7b00a

6 files changed

+151
-9
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- Preserve source maps for generated CSS ([#7588](https://github.com/tailwindlabs/tailwindcss/pull/7588))
1414
- Split box shadows on top-level commas only ([#7479](https://github.com/tailwindlabs/tailwindcss/pull/7479))
1515
- Use local user CSS cache for `@apply` ([#7524](https://github.com/tailwindlabs/tailwindcss/pull/7524))
16+
- Invalidate context when main CSS changes ([#7626](https://github.com/tailwindlabs/tailwindcss/pull/7626))
1617

1718
### Changed
1819

src/lib/cacheInvalidation.js

+52
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import crypto from 'crypto'
2+
import * as sharedState from './sharedState'
3+
4+
/**
5+
* Calculate the hash of a string.
6+
*
7+
* This doesn't need to be cryptographically secure or
8+
* anything like that since it's used only to detect
9+
* when the CSS changes to invalidate the context.
10+
*
11+
* This is wrapped in a try/catch because it's really dependent
12+
* on how Node itself is build and the environment and OpenSSL
13+
* version / build that is installed on the user's machine.
14+
*
15+
* Based on the environment this can just outright fail.
16+
*
17+
* See https://github.com/nodejs/node/issues/40455
18+
*
19+
* @param {string} str
20+
*/
21+
function getHash(str) {
22+
try {
23+
return crypto.createHash('md5').update(str, 'utf-8').digest('binary')
24+
} catch (err) {
25+
return ''
26+
}
27+
}
28+
29+
/**
30+
* Determine if the CSS tree is different from the
31+
* previous version for the given `sourcePath`.
32+
*
33+
* @param {string} sourcePath
34+
* @param {import('postcss').Node} root
35+
*/
36+
export function hasContentChanged(sourcePath, root) {
37+
let css = root.toString()
38+
39+
// We only care about files with @tailwind directives
40+
// Other files use an existing context
41+
if (!css.includes('@tailwind')) {
42+
return false
43+
}
44+
45+
let existingHash = sharedState.sourceHashMap.get(sourcePath)
46+
let rootHash = getHash(css)
47+
let didChange = existingHash !== rootHash
48+
49+
sharedState.sourceHashMap.set(sourcePath, rootHash)
50+
51+
return didChange
52+
}

src/lib/setupContextUtils.js

+6-1
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import log from '../util/log'
2020
import negateValue from '../util/negateValue'
2121
import isValidArbitraryValue from '../util/isValidArbitraryValue'
2222
import { generateRules } from './generateRules'
23+
import { hasContentChanged } from './cacheInvalidation.js'
2324

2425
function prefix(context, selector) {
2526
let prefix = context.tailwindConfig.prefix
@@ -790,6 +791,8 @@ export function createContext(tailwindConfig, changedContent = [], root = postcs
790791
let resolvedPlugins = resolvePlugins(context, root)
791792
registerPlugins(resolvedPlugins, context)
792793

794+
sharedState.contextInvalidationCount++
795+
793796
return context
794797
}
795798

@@ -822,14 +825,16 @@ export function getContext(
822825
existingContext = context
823826
}
824827

828+
let cssDidChange = hasContentChanged(sourcePath, root)
829+
825830
// If there's already a context in the cache and we don't need to
826831
// reset the context, return the cached context.
827832
if (existingContext) {
828833
let contextDependenciesChanged = trackModified(
829834
[...contextDependencies],
830835
getFileModifiedMap(existingContext)
831836
)
832-
if (!contextDependenciesChanged) {
837+
if (!contextDependenciesChanged && !cssDidChange) {
833838
return [existingContext, false]
834839
}
835840
}

src/lib/sharedState.js

+7
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,13 @@ export const env = {
55
export const contextMap = new Map()
66
export const configContextMap = new Map()
77
export const contextSourcesMap = new Map()
8+
/**
9+
* A map of source files to their sizes / hashes
10+
*
11+
* @type {Map<string, string>}
12+
*/
13+
export const sourceHashMap = new Map()
14+
export const contextInvalidationCount = 0
815
export const NOT_ON_DEMAND = new String('*')
916

1017
export function resolveDebug(debug) {

tests/context-reuse.test.html

+3-1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,7 @@
77
<title>Title</title>
88
<link rel="stylesheet" href="./tailwind.css" />
99
</head>
10-
<body></body>
10+
<body>
11+
<div class="only:custom-utility"></div>
12+
</body>
1113
</html>

tests/context-reuse.test.js

+82-7
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ const configPath = path.resolve(__dirname, './context-reuse.tailwind.config.js')
77
const { css } = require('./util/run.js')
88

99
function run(input, config = {}, from = null) {
10-
from = from || path.resolve(__filename)
10+
let { currentTestName } = expect.getState()
11+
12+
from = `${path.resolve(__filename)}?test=${currentTestName}&${from}`
1113

1214
return postcss(tailwind(config)).process(input, { from })
1315
}
@@ -26,16 +28,14 @@ afterEach(async () => {
2628
})
2729

2830
it('re-uses the context across multiple files with the same config', async () => {
29-
let from = path.resolve(__filename)
30-
3131
let results = [
32-
await run(`@tailwind utilities;`, configPath, `${from}?id=1`),
32+
await run(`@tailwind utilities;`, configPath, `id=1`),
3333

3434
// Using @apply directives should still re-use the context
3535
// They depend on the config but do not the other way around
36-
await run(`body { @apply bg-blue-400; }`, configPath, `${from}?id=2`),
37-
await run(`body { @apply text-red-400; }`, configPath, `${from}?id=3`),
38-
await run(`body { @apply mb-4; }`, configPath, `${from}?id=4`),
36+
await run(`body { @apply bg-blue-400; }`, configPath, `id=2`),
37+
await run(`body { @apply text-red-400; }`, configPath, `id=3`),
38+
await run(`body { @apply mb-4; }`, configPath, `id=4`),
3939
]
4040

4141
let dependencies = results.map((result) => {
@@ -85,3 +85,78 @@ it('re-uses the context across multiple files with the same config', async () =>
8585
// And none of this should have resulted in multiple contexts being created
8686
expect(sharedState.contextSourcesMap.size).toBe(1)
8787
})
88+
89+
it('updates layers when any CSS containing @tailwind directives changes', async () => {
90+
let result
91+
92+
// Compile the initial version once
93+
let input = css`
94+
@tailwind utilities;
95+
@layer utilities {
96+
.custom-utility {
97+
color: orange;
98+
}
99+
}
100+
`
101+
102+
result = await run(input, configPath, `id=1`)
103+
104+
expect(result.css).toMatchFormattedCss(css`
105+
.only\:custom-utility:only-child {
106+
color: orange;
107+
}
108+
`)
109+
110+
// Save the file with a change
111+
input = css`
112+
@tailwind utilities;
113+
@layer utilities {
114+
.custom-utility {
115+
color: blue;
116+
}
117+
}
118+
`
119+
120+
result = await run(input, configPath, `id=1`)
121+
122+
expect(result.css).toMatchFormattedCss(css`
123+
.only\:custom-utility:only-child {
124+
color: blue;
125+
}
126+
`)
127+
})
128+
129+
it('invalidates the context when any CSS containing @tailwind directives changes', async () => {
130+
sharedState.contextInvalidationCount = 0
131+
sharedState.sourceHashMap.clear()
132+
133+
// Save the file a handful of times with no changes
134+
// This builds the context at most once
135+
for (let n = 0; n < 5; n++) {
136+
await run(`@tailwind utilities;`, configPath, `id=1`)
137+
}
138+
139+
expect(sharedState.contextInvalidationCount).toBe(1)
140+
141+
// Save the file twice with a change
142+
// This should rebuild the context again but only once
143+
await run(`@tailwind utilities; .foo {}`, configPath, `id=1`)
144+
await run(`@tailwind utilities; .foo {}`, configPath, `id=1`)
145+
146+
expect(sharedState.contextInvalidationCount).toBe(2)
147+
148+
// Save the file twice with a content but not length change
149+
// This should rebuild the context two more times
150+
await run(`@tailwind utilities; .bar {}`, configPath, `id=1`)
151+
await run(`@tailwind utilities; .baz {}`, configPath, `id=1`)
152+
153+
expect(sharedState.contextInvalidationCount).toBe(4)
154+
155+
// Save a file with a change that does not affect the context
156+
// No invalidation should occur
157+
await run(`.foo { @apply mb-1; }`, configPath, `id=2`)
158+
await run(`.foo { @apply mb-1; }`, configPath, `id=2`)
159+
await run(`.foo { @apply mb-1; }`, configPath, `id=2`)
160+
161+
expect(sharedState.contextInvalidationCount).toBe(4)
162+
})

0 commit comments

Comments
 (0)