Split context (#4489)

* Remove watcher from context

* WIP

* WIP

* format

* abstract configDependencies

* abstract fileModifiedMap

* abstract candidateFiles

* abstract changedContent to each strategy

* little bit of cleanup

* formatting

Co-authored-by: Adam Wathan <adam.wathan@gmail.com>
This commit is contained in:
Robin Malfait 2021-05-27 18:29:07 +02:00 committed by GitHub
parent d1e9632064
commit dfdfba25a2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 396 additions and 248 deletions

View File

@ -1,124 +0,0 @@
// @ts-check
import chokidar from 'chokidar'
import crypto from 'crypto'
import fs from 'fs'
import os from 'os'
import path from 'path'
import log from '../../util/log'
import { env } from './sharedState'
// Earmarks a directory for our touch files.
// If the directory already exists we delete any existing touch files,
// invalidating any caches associated with them.
let touchDir =
env.TAILWIND_TOUCH_DIR || path.join(os.homedir() || os.tmpdir(), '.tailwindcss', 'touch')
if (!env.TAILWIND_DISABLE_TOUCH) {
if (fs.existsSync(touchDir)) {
for (let file of fs.readdirSync(touchDir)) {
try {
fs.unlinkSync(path.join(touchDir, file))
} catch (_err) {}
}
} else {
fs.mkdirSync(touchDir, { recursive: true })
}
}
// This is used to trigger rebuilds. Just updating the timestamp
// is significantly faster than actually writing to the file (10x).
function touch(filename) {
let time = new Date()
try {
fs.utimesSync(filename, time, time)
} catch (err) {
fs.closeSync(fs.openSync(filename, 'w'))
}
}
export function rebootWatcher(context) {
if (context.touchFile === null) {
context.touchFile = generateTouchFileName()
touch(context.touchFile)
}
if (env.TAILWIND_MODE === 'build') {
return
}
if (
env.TAILWIND_MODE === 'watch' ||
(env.TAILWIND_MODE === undefined && env.NODE_ENV === 'development')
) {
Promise.resolve(context.watcher ? context.watcher.close() : null).then(() => {
log.info([
'Tailwind CSS is watching for changes...',
'https://tailwindcss.com/docs/just-in-time-mode#watch-mode-and-one-off-builds',
])
context.watcher = chokidar.watch([...context.candidateFiles, ...context.configDependencies], {
ignoreInitial: true,
})
context.watcher.on('add', (file) => {
let changedFile = path.resolve('.', file)
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
context.changedContent.push({ content, extension })
touch(context.touchFile)
})
context.watcher.on('change', (file) => {
// If it was a config dependency, touch the config file to trigger a new context.
// This is not really that clean of a solution but it's the fastest, because we
// can do a very quick check on each build to see if the config has changed instead
// of having to get all of the module dependencies and check every timestamp each
// time.
if (context.configDependencies.has(file)) {
for (let dependency of context.configDependencies) {
delete require.cache[require.resolve(dependency)]
}
touch(context.configPath)
} else {
let changedFile = path.resolve('.', file)
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
context.changedContent.push({ content, extension })
touch(context.touchFile)
}
})
context.watcher.on('unlink', (file) => {
// Touch the config file if any of the dependencies are deleted.
if (context.configDependencies.has(file)) {
for (let dependency of context.configDependencies) {
delete require.cache[require.resolve(dependency)]
}
touch(context.configPath)
}
})
})
}
}
function generateTouchFileName() {
let chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
let randomChars = ''
let randomCharsLength = 12
let bytes = null
try {
bytes = crypto.randomBytes(randomCharsLength)
} catch (_error) {
bytes = crypto.pseudoRandomBytes(randomCharsLength)
}
for (let i = 0; i < randomCharsLength; i++) {
randomChars += chars[bytes[i] % chars.length]
}
return path.join(touchDir, `touch-${process.pid}-${randomChars}`)
}

View File

@ -1,10 +1,8 @@
import fs from 'fs'
import url from 'url'
import path from 'path'
import postcss from 'postcss'
import dlv from 'dlv'
import selectorParser from 'postcss-selector-parser'
import normalizePath from 'normalize-path'
import transformThemeValue from '../../util/transformThemeValue'
import parseObjectStyles from '../../util/parseObjectStyles'
@ -304,7 +302,15 @@ function buildPluginApi(tailwindConfig, context, { variantList, variantMap, offs
}
}
function trackModified(files, context) {
let fileModifiedMapCache = new WeakMap()
export function getFileModifiedMap(context) {
if (!fileModifiedMapCache.has(context)) {
fileModifiedMapCache.set(context, new Map())
}
return fileModifiedMapCache.get(context)
}
function trackModified(files, fileModifiedMap) {
let changed = false
for (let file of files) {
@ -314,11 +320,11 @@ function trackModified(files, context) {
let pathname = parsed.href.replace(parsed.hash, '').replace(parsed.search, '')
let newModified = fs.statSync(decodeURIComponent(pathname)).mtimeMs
if (!context.fileModifiedMap.has(file) || newModified > context.fileModifiedMap.get(file)) {
if (!fileModifiedMap.has(file) || newModified > fileModifiedMap.get(file)) {
changed = true
}
context.fileModifiedMap.set(file, newModified)
fileModifiedMap.set(file, newModified)
}
return changed
@ -477,48 +483,18 @@ let contextMap = sharedState.contextMap
let configContextMap = sharedState.configContextMap
let contextSourcesMap = sharedState.contextSourcesMap
function cleanupContext(context) {
if (context.watcher) {
context.watcher.close()
}
}
export function getContext(
configOrPath,
tailwindDirectives,
registerDependency,
root,
result,
getTailwindConfig
tailwindConfig,
userConfigPath,
tailwindConfigHash,
contextDependencies
) {
let sourcePath = result.opts.from
let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] =
getTailwindConfig(configOrPath)
let isConfigFile = userConfigPath !== null
let contextDependencies = new Set(configDependencies)
// If there are no @tailwind rules, we don't consider this CSS file or it's dependencies
// to be dependencies of the context. Can reuse the context even if they change.
// We may want to think about `@layer` being part of this trigger too, but it's tough
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
// in another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0) {
// Add current css file as a context dependencies.
contextDependencies.add(sourcePath)
// Add all css @import dependencies as context dependencies.
for (let message of result.messages) {
if (message.type === 'dependency') {
contextDependencies.add(message.file)
}
}
}
for (let file of configDependencies) {
registerDependency(file)
}
env.DEBUG && console.log('Source path:', sourcePath)
let existingContext
@ -536,7 +512,10 @@ export function getContext(
// 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], existingContext)
let contextDependenciesChanged = trackModified(
[...contextDependencies],
getFileModifiedMap(existingContext)
)
if (!contextDependenciesChanged) {
return [existingContext, false]
}
@ -553,49 +532,30 @@ export function getContext(
contextSourcesMap.get(oldContext).delete(sourcePath)
if (contextSourcesMap.get(oldContext).size === 0) {
contextSourcesMap.delete(oldContext)
cleanupContext(oldContext)
for (let disposable of oldContext.disposables.splice(0)) {
disposable(oldContext)
}
}
}
}
env.DEBUG && console.log('Setting up new context...')
let purgeContent = Array.isArray(tailwindConfig.purge)
? tailwindConfig.purge
: tailwindConfig.purge.content
let context = {
watcher: null,
touchFile: null,
configPath: userConfigPath,
configDependencies: new Set(),
candidateFiles: purgeContent
.filter((item) => typeof item === 'string')
.map((purgePath) =>
normalizePath(
path.resolve(
userConfigPath === null ? process.cwd() : path.dirname(userConfigPath),
purgePath
)
)
),
fileModifiedMap: new Map(),
// ---
ruleCache: new Set(), // Hit
classCache: new Map(), // Hit
applyClassCache: new Map(), // Hit
notClassCache: new Set(), // Hit
postCssNodeCache: new Map(), // Hit
candidateRuleMap: new Map(), // Hit
tailwindConfig: tailwindConfig, // Hit
changedContent: purgeContent // Hit
.filter((item) => typeof item.raw === 'string')
.map(({ raw, extension }) => ({ content: raw, extension })),
variantMap: new Map(), // Hit
stylesheetCache: null, // Hit
disposables: [],
ruleCache: new Set(),
classCache: new Map(),
applyClassCache: new Map(),
notClassCache: new Set(),
postCssNodeCache: new Map(),
candidateRuleMap: new Map(),
tailwindConfig,
changedContent: [],
variantMap: new Map(),
stylesheetCache: null,
}
trackModified([...contextDependencies], context)
trackModified([...contextDependencies], getFileModifiedMap(context))
// ---

View File

@ -5,6 +5,7 @@ import fastGlob from 'fast-glob'
import isGlob from 'is-glob'
import globParent from 'glob-parent'
import LRU from 'quick-lru'
import normalizePath from 'normalize-path'
import hash from '../../util/hashConfig'
import getModuleDependencies from '../../lib/getModuleDependencies'
@ -15,10 +16,29 @@ import resolveConfigPath from '../../util/resolveConfigPath'
import { env } from './sharedState'
import { getContext } from './setupContextUtils'
import { getContext, getFileModifiedMap } from './setupContextUtils'
let configPathCache = new LRU({ maxSize: 100 })
let candidateFilesCache = new WeakMap()
function getCandidateFiles(context, userConfigPath, tailwindConfig) {
if (candidateFilesCache.has(context)) {
return candidateFilesCache.get(context)
}
let purgeContent = Array.isArray(tailwindConfig.purge)
? tailwindConfig.purge
: tailwindConfig.purge.content
let basePath = userConfigPath === null ? process.cwd() : path.dirname(userConfigPath)
let candidateFiles = purgeContent
.filter((item) => typeof item === 'string')
.map((purgePath) => normalizePath(path.resolve(basePath, purgePath)))
return candidateFilesCache.set(context, candidateFiles).get(context)
}
// Get the config object based on a path
function getTailwindConfig(configOrPath) {
let userConfigPath = resolveConfigPath(configOrPath)
@ -62,19 +82,34 @@ function getTailwindConfig(configOrPath) {
return [newConfig, null, hash(newConfig), []]
}
function resolveChangedFiles(context) {
function resolvedChangedContent(context, candidateFiles, fileModifiedMap) {
let changedContent = (
Array.isArray(context.tailwindConfig.purge)
? context.tailwindConfig.purge
: context.tailwindConfig.purge.content
)
.filter((item) => typeof item.raw === 'string')
.map(({ raw, extension }) => ({ content: raw, extension }))
for (let changedFile of resolveChangedFiles(candidateFiles, fileModifiedMap)) {
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
changedContent.push({ content, extension })
}
return changedContent
}
function resolveChangedFiles(candidateFiles, fileModifiedMap) {
let changedFiles = new Set()
env.DEBUG && console.time('Finding changed files')
let files = fastGlob.sync(context.candidateFiles)
let files = fastGlob.sync(candidateFiles)
for (let file of files) {
let prevModified = context.fileModifiedMap.has(file)
? context.fileModifiedMap.get(file)
: -Infinity
let prevModified = fileModifiedMap.has(file) ? fileModifiedMap.get(file) : -Infinity
let modified = fs.statSync(file).mtimeMs
if (modified > prevModified) {
changedFiles.add(file)
context.fileModifiedMap.set(file, modified)
fileModifiedMap.set(file, modified)
}
}
env.DEBUG && console.timeEnd('Finding changed files')
@ -88,14 +123,10 @@ function resolveChangedFiles(context) {
// plugins) then return it
export default function setupTrackingContext(configOrPath, tailwindDirectives, registerDependency) {
return (result, root) => {
let [context] = getContext(
configOrPath,
tailwindDirectives,
registerDependency,
root,
result,
getTailwindConfig
)
let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] =
getTailwindConfig(configOrPath)
let contextDependencies = new Set(configDependencies)
// If there are no @tailwind rules, we don't consider this CSS file or it's dependencies
// to be dependencies of the context. Can reuse the context even if they change.
@ -103,8 +134,39 @@ export default function setupTrackingContext(configOrPath, tailwindDirectives, r
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
// in another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0) {
// Add current css file as a context dependencies.
contextDependencies.add(result.opts.from)
// Add all css @import dependencies as context dependencies.
for (let message of result.messages) {
if (message.type === 'dependency') {
contextDependencies.add(message.file)
}
}
}
let [context] = getContext(
tailwindDirectives,
root,
result,
tailwindConfig,
userConfigPath,
tailwindConfigHash,
contextDependencies
)
let candidateFiles = getCandidateFiles(context, userConfigPath, tailwindConfig)
// If there are no @tailwind rules, we don't consider this CSS file or it's dependencies
// to be dependencies of the context. Can reuse the context even if they change.
// We may want to think about `@layer` being part of this trigger too, but it's tough
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
// in another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0) {
let fileModifiedMap = getFileModifiedMap(context)
// Add template paths as postcss dependencies.
for (let maybeGlob of context.candidateFiles) {
for (let maybeGlob of candidateFiles) {
if (isGlob(maybeGlob)) {
// rollup-plugin-postcss does not support dir-dependency messages
// but directories can be watched in the same way as files
@ -117,13 +179,15 @@ export default function setupTrackingContext(configOrPath, tailwindDirectives, r
}
}
for (let changedFile of resolveChangedFiles(context)) {
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
context.changedContent.push({ content, extension })
for (let changedContent of resolvedChangedContent(context, candidateFiles, fileModifiedMap)) {
context.changedContent.push(changedContent)
}
}
for (let file of configDependencies) {
registerDependency(file)
}
return context
}
}

View File

@ -1,21 +1,212 @@
import fs from 'fs'
import path from 'path'
import crypto from 'crypto'
import os from 'os'
import chokidar from 'chokidar'
import fastGlob from 'fast-glob'
import LRU from 'quick-lru'
import normalizePath from 'normalize-path'
import hash from '../../util/hashConfig'
import log from '../../util/log'
import getModuleDependencies from '../../lib/getModuleDependencies'
import resolveConfig from '../../../resolveConfig'
import resolveConfigPath from '../../util/resolveConfigPath'
import { rebootWatcher } from './rebootWatcher'
import { getContext } from './setupContextUtils'
import { env } from './sharedState'
// Earmarks a directory for our touch files.
// If the directory already exists we delete any existing touch files,
// invalidating any caches associated with them.
let touchDir =
env.TAILWIND_TOUCH_DIR || path.join(os.homedir() || os.tmpdir(), '.tailwindcss', 'touch')
if (!env.TAILWIND_DISABLE_TOUCH) {
if (fs.existsSync(touchDir)) {
for (let file of fs.readdirSync(touchDir)) {
try {
fs.unlinkSync(path.join(touchDir, file))
} catch (_err) {}
}
} else {
fs.mkdirSync(touchDir, { recursive: true })
}
}
// This is used to trigger rebuilds. Just updating the timestamp
// is significantly faster than actually writing to the file (10x).
function touch(filename) {
let time = new Date()
try {
fs.utimesSync(filename, time, time)
} catch (err) {
fs.closeSync(fs.openSync(filename, 'w'))
}
}
let watchers = new WeakMap()
function getWatcher(context) {
if (watchers.has(context)) {
return watchers.get(context)
}
return null
}
function setWatcher(context, watcher) {
return watchers.set(context, watcher)
}
let touchFiles = new WeakMap()
function getTouchFile(context) {
if (touchFiles.has(context)) {
return touchFiles.get(context)
}
return null
}
function setTouchFile(context, touchFile) {
return touchFiles.set(context, touchFile)
}
let configPaths = new WeakMap()
function getConfigPath(context, configOrPath) {
if (!configPaths.has(context)) {
configPaths.set(context, resolveConfigPath(configOrPath))
}
return configPaths.get(context)
}
function rebootWatcher(context, configPath, configDependencies, candidateFiles) {
let touchFile = getTouchFile(context)
if (touchFile === null) {
touchFile = generateTouchFileName()
setTouchFile(context, touchFile)
touch(touchFile)
}
if (env.TAILWIND_MODE === 'build') {
return
}
if (
env.TAILWIND_MODE === 'watch' ||
(env.TAILWIND_MODE === undefined && env.NODE_ENV === 'development')
) {
let watcher = getWatcher(context)
Promise.resolve(watcher ? watcher.close() : null).then(() => {
log.info([
'Tailwind CSS is watching for changes...',
'https://tailwindcss.com/docs/just-in-time-mode#watch-mode-and-one-off-builds',
])
watcher = chokidar.watch([...candidateFiles, ...configDependencies], {
ignoreInitial: true,
})
setWatcher(context, watcher)
watcher.on('add', (file) => {
let changedFile = path.resolve('.', file)
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
context.changedContent.push({ content, extension })
touch(touchFile)
})
watcher.on('change', (file) => {
// If it was a config dependency, touch the config file to trigger a new context.
// This is not really that clean of a solution but it's the fastest, because we
// can do a very quick check on each build to see if the config has changed instead
// of having to get all of the module dependencies and check every timestamp each
// time.
if (configDependencies.has(file)) {
for (let dependency of configDependencies) {
delete require.cache[require.resolve(dependency)]
}
touch(configPath)
} else {
let changedFile = path.resolve('.', file)
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
context.changedContent.push({ content, extension })
touch(touchFile)
}
})
watcher.on('unlink', (file) => {
// Touch the config file if any of the dependencies are deleted.
if (configDependencies.has(file)) {
for (let dependency of configDependencies) {
delete require.cache[require.resolve(dependency)]
}
touch(configPath)
}
})
})
}
}
function generateTouchFileName() {
let chars = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'
let randomChars = ''
let randomCharsLength = 12
let bytes = null
try {
bytes = crypto.randomBytes(randomCharsLength)
} catch (_error) {
bytes = crypto.pseudoRandomBytes(randomCharsLength)
}
for (let i = 0; i < randomCharsLength; i++) {
randomChars += chars[bytes[i] % chars.length]
}
return path.join(touchDir, `touch-${process.pid}-${randomChars}`)
}
let configPathCache = new LRU({ maxSize: 100 })
let configDependenciesCache = new WeakMap()
function getConfigDependencies(context) {
if (!configDependenciesCache.has(context)) {
configDependenciesCache.set(context, new Set())
}
return configDependenciesCache.get(context)
}
let candidateFilesCache = new WeakMap()
function getCandidateFiles(context, userConfigPath, tailwindConfig) {
if (candidateFilesCache.has(context)) {
return candidateFilesCache.get(context)
}
let purgeContent = Array.isArray(tailwindConfig.purge)
? tailwindConfig.purge
: tailwindConfig.purge.content
let basePath = userConfigPath === null ? process.cwd() : path.dirname(userConfigPath)
let candidateFiles = purgeContent
.filter((item) => typeof item === 'string')
.map((purgePath) => normalizePath(path.resolve(basePath, purgePath)))
return candidateFilesCache.set(context, candidateFiles).get(context)
}
// Get the config object based on a path
function getTailwindConfig(configOrPath) {
let userConfigPath = resolveConfigPath(configOrPath)
@ -46,18 +237,37 @@ function getTailwindConfig(configOrPath) {
return [newConfig, null, hash(newConfig), [userConfigPath]]
}
function resolveChangedFiles(context) {
function resolvedChangedContent(context, candidateFiles) {
let changedContent = (
Array.isArray(context.tailwindConfig.purge)
? context.tailwindConfig.purge
: context.tailwindConfig.purge.content
)
.filter((item) => typeof item.raw === 'string')
.map(({ raw, extension }) => ({ content: raw, extension }))
for (let changedFile of resolveChangedFiles(context, candidateFiles)) {
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
changedContent.push({ content, extension })
}
return changedContent
}
let scannedContentCache = new WeakMap()
function resolveChangedFiles(context, candidateFiles) {
let changedFiles = new Set()
// If we're not set up and watching files ourselves, we need to do
// the work of grabbing all of the template files for candidate
// detection.
if (!context.scannedContent) {
let files = fastGlob.sync(context.candidateFiles)
if (!scannedContentCache.has(context)) {
let files = fastGlob.sync(candidateFiles)
for (let file of files) {
changedFiles.add(file)
}
context.scannedContent = true
scannedContentCache.set(context, true)
}
return changedFiles
@ -70,40 +280,78 @@ function resolveChangedFiles(context) {
// plugins) then return it
export default function setupWatchingContext(configOrPath, tailwindDirectives, registerDependency) {
return (result, root) => {
let [context, newContext] = getContext(
configOrPath,
tailwindDirectives,
registerDependency,
root,
result,
getTailwindConfig
)
let [tailwindConfig, userConfigPath, tailwindConfigHash, configDependencies] =
getTailwindConfig(configOrPath)
if (context.configPath !== null) {
for (let dependency of getModuleDependencies(context.configPath)) {
if (dependency.file === context.configPath) {
continue
let contextDependencies = new Set(configDependencies)
// If there are no @tailwind rules, we don't consider this CSS file or it's dependencies
// to be dependencies of the context. Can reuse the context even if they change.
// We may want to think about `@layer` being part of this trigger too, but it's tough
// because it's impossible for a layer in one file to end up in the actual @tailwind rule
// in another file since independent sources are effectively isolated.
if (tailwindDirectives.size > 0) {
// Add current css file as a context dependencies.
contextDependencies.add(result.opts.from)
// Add all css @import dependencies as context dependencies.
for (let message of result.messages) {
if (message.type === 'dependency') {
contextDependencies.add(message.file)
}
context.configDependencies.add(dependency.file)
}
}
if (newContext) {
rebootWatcher(context)
let [context, isNewContext] = getContext(
tailwindDirectives,
root,
result,
tailwindConfig,
userConfigPath,
tailwindConfigHash,
contextDependencies
)
let candidateFiles = getCandidateFiles(context, userConfigPath, tailwindConfig)
let contextConfigDependencies = getConfigDependencies(context)
for (let file of configDependencies) {
registerDependency(file)
}
context.disposables.push((oldContext) => {
let watcher = getWatcher(oldContext)
if (watcher !== null) {
watcher.close()
}
})
let configPath = getConfigPath(context, configOrPath)
if (configPath !== null) {
for (let dependency of getModuleDependencies(configPath)) {
if (dependency.file === configPath) {
continue
}
contextConfigDependencies.add(dependency.file)
}
}
if (isNewContext) {
rebootWatcher(context, configPath, contextConfigDependencies, candidateFiles)
}
// Register our temp file as a dependency — we write to this file
// to trigger rebuilds.
if (context.touchFile) {
registerDependency(context.touchFile)
let touchFile = getTouchFile(context)
if (touchFile) {
registerDependency(touchFile)
}
if (tailwindDirectives.size > 0) {
for (let changedFile of resolveChangedFiles(context)) {
let content = fs.readFileSync(changedFile, 'utf8')
let extension = path.extname(changedFile).slice(1)
context.changedContent.push({ content, extension })
for (let changedContent of resolvedChangedContent(context, candidateFiles)) {
context.changedContent.push(changedContent)
}
}