From 4a25bc1fd5106704663ac9737dd3ca2077ed4238 Mon Sep 17 00:00:00 2001 From: Robin Malfait Date: Tue, 9 Apr 2024 00:10:08 +0200 Subject: [PATCH] Ensure deterministic SSR builds in `@tailwindcss/vite` (#13457) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ensure we wait in the `build` step as well It looks like when running `astro build` we only run this `build` step and not the `dev` step where we already use the `waitForRequestsIdle` code. Adding this to the `build` part as well does generate the correct result. * update changelog * fix typo * add comment * Don’t run transforms more than necessary * Don’t remove modules from the graph during SSR * Update changelog * Add `preview` script --------- Co-authored-by: Jordan Pittman --- CHANGELOG.md | 4 +- packages/@tailwindcss-vite/src/index.ts | 76 +++++++++++++++++-------- playgrounds/vite/package.json | 3 +- 3 files changed, 57 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fc6a4c36..ef972cec1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- Nothing yet! +### Fixed + +- Ensure deterministic SSR builds in `@tailwindcss/vite` ([#13457](https://github.com/tailwindlabs/tailwindcss/pull/13457)) ## [4.0.0-alpha.13] - 2024-04-04 diff --git a/packages/@tailwindcss-vite/src/index.ts b/packages/@tailwindcss-vite/src/index.ts index 815ed14fb..79b94d9fa 100644 --- a/packages/@tailwindcss-vite/src/index.ts +++ b/packages/@tailwindcss-vite/src/index.ts @@ -7,14 +7,21 @@ import type { Plugin, Rollup, Update, ViteDevServer } from 'vite' export default function tailwindcss(): Plugin[] { let server: ViteDevServer | null = null let candidates = new Set() - // In serve mode, we treat this as a set, storing storing empty strings. + // In serve mode this is treated as a set — the content doesn't matter. // In build mode, we store file contents to use them in renderChunk. - let cssModules: Record = {} + let cssModules: Record< + string, + { + content: string + handled: boolean + } + > = {} + let isSSR = false let minify = false let cssPlugins: readonly Plugin[] = [] // Trigger update to all CSS modules - function updateCssModules() { + function updateCssModules(isSSR: boolean) { // If we're building then we don't need to update anything if (!server) return @@ -22,9 +29,14 @@ export default function tailwindcss(): Plugin[] { for (let id of Object.keys(cssModules)) { let cssModule = server.moduleGraph.getModuleById(id) if (!cssModule) { - // It is safe to remove the item here since we're iterating on a copy of - // the keys. - delete cssModules[id] + // Note: Removing this during SSR is not safe and will produce + // inconsistent results based on the timing of the removal and + // the order / timing of transforms. + if (!isSSR) { + // It is safe to remove the item here since we're iterating on a copy + // of the keys. + delete cssModules[id] + } continue } @@ -85,7 +97,7 @@ export default function tailwindcss(): Plugin[] { try { // Directly call the plugin's transform function to process the // generated CSS. In build mode, this updates the chunks later used to - // generate the bundle. In serve mode, the transformed souce should be + // generate the bundle. In serve mode, the transformed source should be // applied in transform. let result = await transformHandler.call(transformPluginContext, css, id) if (!result) continue @@ -113,16 +125,21 @@ export default function tailwindcss(): Plugin[] { async configResolved(config) { minify = config.build.cssMinify !== false - // Apply the vite:css plugin to generated CSS for transformations like - // URL path rewriting and image inlining. - // - // In build mode, since renderChunk runs after all transformations, we - // need to also apply vite:css-post. - cssPlugins = config.plugins.filter((plugin) => - ['vite:css', ...(config.command === 'build' ? ['vite:css-post'] : [])].includes( - plugin.name, - ), - ) + isSSR = config.build.ssr !== false && config.build.ssr !== undefined + + let allowedPlugins = [ + // Apply the vite:css plugin to generated CSS for transformations like + // URL path rewriting and image inlining. + 'vite:css', + + // In build mode, since renderChunk runs after all transformations, we + // need to also apply vite:css-post. + ...(config.command === 'build' ? ['vite:css-post'] : []), + ] + + cssPlugins = config.plugins.filter((plugin) => { + return allowedPlugins.includes(plugin.name) + }) }, // Scan index.html for candidates @@ -134,18 +151,18 @@ export default function tailwindcss(): Plugin[] { // CSS update will cause an infinite loop. We only trigger if the // candidates have been updated. if (updated) { - updateCssModules() + updateCssModules(isSSR) } }, // Scan all non-CSS files for candidates - transform(src, id) { + transform(src, id, options) { if (id.includes('/.vite/')) return let extension = getExtension(id) if (extension === '' || extension === 'css') return scan(src, extension) - updateCssModules() + updateCssModules(options?.ssr ?? false) }, }, @@ -163,7 +180,7 @@ export default function tailwindcss(): Plugin[] { if (!isTailwindCssFile(id, src)) return // In serve mode, we treat cssModules as a set, ignoring the value. - cssModules[id] = '' + cssModules[id] = { content: '', handled: true } if (!options?.ssr) { // Wait until all other files have been processed, so we can extract @@ -184,15 +201,26 @@ export default function tailwindcss(): Plugin[] { transform(src, id) { if (!isTailwindCssFile(id, src)) return - cssModules[id] = src + cssModules[id] = { content: src, handled: false } }, // renderChunk runs in the bundle generation stage after all transforms. // We must run before `enforce: post` so the updated chunks are picked up // by vite:css-post. async renderChunk(_code, _chunk) { - for (let [cssFile, css] of Object.entries(cssModules)) { - await transformWithPlugins(this, cssFile, generateOptimizedCss(css)) + for (let [id, file] of Object.entries(cssModules)) { + if (file.handled) { + continue + } + + let css = generateOptimizedCss(file.content) + + // These plugins have side effects which, during build, results in CSS + // being written to the output dir. We need to run them here to ensure + // the CSS is written before the bundle is generated. + await transformWithPlugins(this, id, css) + + file.handled = true } }, }, diff --git a/playgrounds/vite/package.json b/playgrounds/vite/package.json index 065a6e1f7..cddd7b795 100644 --- a/playgrounds/vite/package.json +++ b/playgrounds/vite/package.json @@ -5,7 +5,8 @@ "scripts": { "lint": "tsc --noEmit", "dev": "bun --bun vite ./src --config ./vite.config.ts", - "build": "bun --bun vite build ./src --outDir ../dist --config ./vite.config.ts --emptyOutDir" + "build": "bun --bun vite build ./src --outDir ../dist --config ./vite.config.ts --emptyOutDir", + "preview": "bun --bun vite preview" }, "dependencies": { "@tailwindcss/vite": "workspace:^",