Skip to content

Commit

Permalink
Invalidate context when CSS changes
Browse files Browse the repository at this point in the history
  • Loading branch information
thecrypticace committed Feb 25, 2022
1 parent 7a24b3f commit dc7b00a
Show file tree
Hide file tree
Showing 6 changed files with 151 additions and 9 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Preserve source maps for generated CSS ([#7588](https://github.com/tailwindlabs/tailwindcss/pull/7588))
- Split box shadows on top-level commas only ([#7479](https://github.com/tailwindlabs/tailwindcss/pull/7479))
- Use local user CSS cache for `@apply` ([#7524](https://github.com/tailwindlabs/tailwindcss/pull/7524))
- Invalidate context when main CSS changes ([#7626](https://github.com/tailwindlabs/tailwindcss/pull/7626))

### Changed

Expand Down
52 changes: 52 additions & 0 deletions src/lib/cacheInvalidation.js
@@ -0,0 +1,52 @@
import crypto from 'crypto'
import * as sharedState from './sharedState'

/**
* Calculate the hash of a string.
*
* This doesn't need to be cryptographically secure or
* anything like that since it's used only to detect
* when the CSS changes to invalidate the context.
*
* This is wrapped in a try/catch because it's really dependent
* on how Node itself is build and the environment and OpenSSL
* version / build that is installed on the user's machine.
*
* Based on the environment this can just outright fail.
*
* See https://github.com/nodejs/node/issues/40455
*
* @param {string} str
*/
function getHash(str) {
try {
return crypto.createHash('md5').update(str, 'utf-8').digest('binary')
} catch (err) {
return ''
}
}

/**
* Determine if the CSS tree is different from the
* previous version for the given `sourcePath`.
*
* @param {string} sourcePath
* @param {import('postcss').Node} root
*/
export function hasContentChanged(sourcePath, root) {
let css = root.toString()

// We only care about files with @tailwind directives
// Other files use an existing context
if (!css.includes('@tailwind')) {
return false
}

let existingHash = sharedState.sourceHashMap.get(sourcePath)
let rootHash = getHash(css)
let didChange = existingHash !== rootHash

sharedState.sourceHashMap.set(sourcePath, rootHash)

return didChange
}
7 changes: 6 additions & 1 deletion src/lib/setupContextUtils.js
Expand Up @@ -20,6 +20,7 @@ import log from '../util/log'
import negateValue from '../util/negateValue'
import isValidArbitraryValue from '../util/isValidArbitraryValue'
import { generateRules } from './generateRules'
import { hasContentChanged } from './cacheInvalidation.js'

function prefix(context, selector) {
let prefix = context.tailwindConfig.prefix
Expand Down Expand Up @@ -790,6 +791,8 @@ export function createContext(tailwindConfig, changedContent = [], root = postcs
let resolvedPlugins = resolvePlugins(context, root)
registerPlugins(resolvedPlugins, context)

sharedState.contextInvalidationCount++

return context
}

Expand Down Expand Up @@ -822,14 +825,16 @@ export function getContext(
existingContext = context
}

let cssDidChange = hasContentChanged(sourcePath, root)

// If there's already a context in the cache and we don't need to
// reset the context, return the cached context.
if (existingContext) {
let contextDependenciesChanged = trackModified(
[...contextDependencies],
getFileModifiedMap(existingContext)
)
if (!contextDependenciesChanged) {
if (!contextDependenciesChanged && !cssDidChange) {
return [existingContext, false]
}
}
Expand Down
7 changes: 7 additions & 0 deletions src/lib/sharedState.js
Expand Up @@ -5,6 +5,13 @@ export const env = {
export const contextMap = new Map()
export const configContextMap = new Map()
export const contextSourcesMap = new Map()
/**
* A map of source files to their sizes / hashes
*
* @type {Map<string, string>}
*/
export const sourceHashMap = new Map()
export const contextInvalidationCount = 0
export const NOT_ON_DEMAND = new String('*')

export function resolveDebug(debug) {
Expand Down
4 changes: 3 additions & 1 deletion tests/context-reuse.test.html
Expand Up @@ -7,5 +7,7 @@
<title>Title</title>
<link rel="stylesheet" href="./tailwind.css" />
</head>
<body></body>
<body>
<div class="only:custom-utility"></div>
</body>
</html>
89 changes: 82 additions & 7 deletions tests/context-reuse.test.js
Expand Up @@ -7,7 +7,9 @@ const configPath = path.resolve(__dirname, './context-reuse.tailwind.config.js')
const { css } = require('./util/run.js')

function run(input, config = {}, from = null) {
from = from || path.resolve(__filename)
let { currentTestName } = expect.getState()

from = `${path.resolve(__filename)}?test=${currentTestName}&${from}`

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

it('re-uses the context across multiple files with the same config', async () => {
let from = path.resolve(__filename)

let results = [
await run(`@tailwind utilities;`, configPath, `${from}?id=1`),
await run(`@tailwind utilities;`, configPath, `id=1`),

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

let dependencies = results.map((result) => {
Expand Down Expand Up @@ -85,3 +85,78 @@ it('re-uses the context across multiple files with the same config', async () =>
// And none of this should have resulted in multiple contexts being created
expect(sharedState.contextSourcesMap.size).toBe(1)
})

it('updates layers when any CSS containing @tailwind directives changes', async () => {
let result

// Compile the initial version once
let input = css`
@tailwind utilities;
@layer utilities {
.custom-utility {
color: orange;
}
}
`

result = await run(input, configPath, `id=1`)

expect(result.css).toMatchFormattedCss(css`
.only\:custom-utility:only-child {
color: orange;
}
`)

// Save the file with a change
input = css`
@tailwind utilities;
@layer utilities {
.custom-utility {
color: blue;
}
}
`

result = await run(input, configPath, `id=1`)

expect(result.css).toMatchFormattedCss(css`
.only\:custom-utility:only-child {
color: blue;
}
`)
})

it('invalidates the context when any CSS containing @tailwind directives changes', async () => {
sharedState.contextInvalidationCount = 0
sharedState.sourceHashMap.clear()

// Save the file a handful of times with no changes
// This builds the context at most once
for (let n = 0; n < 5; n++) {
await run(`@tailwind utilities;`, configPath, `id=1`)
}

expect(sharedState.contextInvalidationCount).toBe(1)

// Save the file twice with a change
// This should rebuild the context again but only once
await run(`@tailwind utilities; .foo {}`, configPath, `id=1`)
await run(`@tailwind utilities; .foo {}`, configPath, `id=1`)

expect(sharedState.contextInvalidationCount).toBe(2)

// Save the file twice with a content but not length change
// This should rebuild the context two more times
await run(`@tailwind utilities; .bar {}`, configPath, `id=1`)
await run(`@tailwind utilities; .baz {}`, configPath, `id=1`)

expect(sharedState.contextInvalidationCount).toBe(4)

// Save a file with a change that does not affect the context
// No invalidation should occur
await run(`.foo { @apply mb-1; }`, configPath, `id=2`)
await run(`.foo { @apply mb-1; }`, configPath, `id=2`)
await run(`.foo { @apply mb-1; }`, configPath, `id=2`)

expect(sharedState.contextInvalidationCount).toBe(4)
})

0 comments on commit dc7b00a

Please sign in to comment.