diff --git a/CHANGELOG.md b/CHANGELOG.md index 0eb3792fc3d0..bf4526574b02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/lib/cacheInvalidation.js b/src/lib/cacheInvalidation.js new file mode 100644 index 000000000000..fa13702e8897 --- /dev/null +++ b/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 +} diff --git a/src/lib/setupContextUtils.js b/src/lib/setupContextUtils.js index e35edcc14145..56986edc9815 100644 --- a/src/lib/setupContextUtils.js +++ b/src/lib/setupContextUtils.js @@ -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 @@ -790,6 +791,8 @@ export function createContext(tailwindConfig, changedContent = [], root = postcs let resolvedPlugins = resolvePlugins(context, root) registerPlugins(resolvedPlugins, context) + sharedState.contextInvalidationCount++ + return context } @@ -822,6 +825,8 @@ 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) { @@ -829,7 +834,7 @@ export function getContext( [...contextDependencies], getFileModifiedMap(existingContext) ) - if (!contextDependenciesChanged) { + if (!contextDependenciesChanged && !cssDidChange) { return [existingContext, false] } } diff --git a/src/lib/sharedState.js b/src/lib/sharedState.js index e0cfe9f3f653..c9c34a518c43 100644 --- a/src/lib/sharedState.js +++ b/src/lib/sharedState.js @@ -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} + */ +export const sourceHashMap = new Map() +export const contextInvalidationCount = 0 export const NOT_ON_DEMAND = new String('*') export function resolveDebug(debug) { diff --git a/tests/context-reuse.test.html b/tests/context-reuse.test.html index 0eb19225d0c8..4f6527ec5fd4 100644 --- a/tests/context-reuse.test.html +++ b/tests/context-reuse.test.html @@ -7,5 +7,7 @@ Title - + +
+ diff --git a/tests/context-reuse.test.js b/tests/context-reuse.test.js index 0619c95cea13..493bf609ed10 100644 --- a/tests/context-reuse.test.js +++ b/tests/context-reuse.test.js @@ -7,7 +7,10 @@ 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) + from = [ + `${path.resolve(__filename)}?test=${expect.getState().currentTestName}`, + from + ].join('&') return postcss(tailwind(config)).process(input, { from }) } @@ -26,16 +29,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) => { @@ -85,3 +86,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) +})