diff --git a/.changeset/postcss-cache-side-effect-imports.md b/.changeset/postcss-cache-side-effect-imports.md new file mode 100644 index 00000000000..36ff0756e9b --- /dev/null +++ b/.changeset/postcss-cache-side-effect-imports.md @@ -0,0 +1,5 @@ +--- +"@remix-run/dev": patch +--- + +Add caching to PostCSS for side-effect imports diff --git a/integration/deterministic-build-output-test.ts b/integration/deterministic-build-output-test.ts index 4004e194ab3..458035340fc 100644 --- a/integration/deterministic-build-output-test.ts +++ b/integration/deterministic-build-output-test.ts @@ -27,11 +27,19 @@ test("builds deterministically under different paths", async () => { // * vanillaExtractPlugin (via app/routes/foo.tsx' .css.ts file import) let init: FixtureInit = { config: { + postcss: true, future: { v2_routeConvention: true, }, }, files: { + "postcss.config.js": js` + module.exports = { + plugins: { + "postcss-import": {}, + }, + }; + `, "app/routes/_index.mdx": "# hello world", "app/routes/foo.tsx": js` export * from "~/foo/bar.server"; diff --git a/integration/hmr-test.ts b/integration/hmr-test.ts index 15ce5667af0..6c9e1ccbd40 100644 --- a/integration/hmr-test.ts +++ b/integration/hmr-test.ts @@ -8,7 +8,7 @@ import getPort, { makeRange } from "get-port"; import type { FixtureInit } from "./helpers/create-fixture"; import { createFixtureProject, css, js, json } from "./helpers/create-fixture"; -test.setTimeout(120_000); +test.setTimeout(150_000); let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ config: { @@ -120,6 +120,16 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ } `, + "app/sideEffectStylesWithImport.css": css` + @import "./importedSideEffectStyle.css"; + `, + + "app/importedSideEffectStyle.css": css` + .importedSideEffectStyle { + font-size: initial; + } + `, + "app/styles.module.css": css` .test { color: initial; @@ -134,6 +144,7 @@ let fixture = (options: { appPort: number; devPort: number }): FixtureInit => ({ import Counter from "./components/counter"; import tailwindStyles from "./tailwind.css"; import stylesWithImport from "./stylesWithImport.css"; + import "./sideEffectStylesWithImport.css"; export const links: LinksFunction = () => [ { rel: "stylesheet", href: tailwindStyles }, @@ -322,6 +333,15 @@ test("HMR", async ({ page, browserName }) => { let originalCssModule = fs.readFileSync(cssModulePath, "utf8"); let mdxPath = path.join(projectDir, "app", "routes", "mdx.mdx"); let originalMdx = fs.readFileSync(mdxPath, "utf8"); + let importedSideEffectStylePath = path.join( + projectDir, + "app", + "importedSideEffectStyle.css" + ); + let originalImportedSideEffectStyle = fs.readFileSync( + importedSideEffectStylePath, + "utf8" + ); // make content and style changed to index route let newCssModule = ` @@ -340,6 +360,14 @@ test("HMR", async ({ page, browserName }) => { `; fs.writeFileSync(importedStylePath, newImportedStyle); + // // make changes to imported side-effect styles + let newImportedSideEffectStyle = ` + .importedSideEffectStyle { + font-size: 32px; + } + `; + fs.writeFileSync(importedSideEffectStylePath, newImportedSideEffectStyle); + // change text, add updated styles, add new Tailwind class ("italic") let newIndex = ` import { useLoaderData } from "@remix-run/react"; @@ -351,7 +379,7 @@ test("HMR", async ({ page, browserName }) => { const t = useLoaderData(); return (
-

Changed

+

Changed

) } @@ -367,6 +395,7 @@ test("HMR", async ({ page, browserName }) => { expect(h1).toHaveCSS("background-color", "rgb(0, 0, 0)"); expect(h1).toHaveCSS("font-style", "italic"); expect(h1).toHaveCSS("font-weight", "800"); + expect(h1).toHaveCSS("font-size", "32px"); // verify that `` value was persisted (i.e. hmr, not full page refresh) expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); @@ -376,6 +405,10 @@ test("HMR", async ({ page, browserName }) => { fs.writeFileSync(indexPath, originalIndex); fs.writeFileSync(importedStylePath, originalImportedStyle); fs.writeFileSync(cssModulePath, originalCssModule); + fs.writeFileSync( + importedSideEffectStylePath, + originalImportedSideEffectStyle + ); await page.getByText("Index Title").waitFor({ timeout: HMR_TIMEOUT_MS }); expect(await page.getByLabel("Root Input").inputValue()).toBe("asdfasdf"); await page.waitForSelector(`#root-counter:has-text("inc 1")`); diff --git a/packages/remix-dev/compiler/plugins/cssImports.ts b/packages/remix-dev/compiler/plugins/cssImports.ts index 3fb382e2b16..424dec6d478 100644 --- a/packages/remix-dev/compiler/plugins/cssImports.ts +++ b/packages/remix-dev/compiler/plugins/cssImports.ts @@ -1,11 +1,10 @@ import * as path from "path"; import * as fse from "fs-extra"; import esbuild from "esbuild"; -import type { Processor } from "postcss"; import invariant from "../../invariant"; import type { Context } from "../context"; -import { getPostcssProcessor } from "../utils/postcss"; +import { getCachedPostcssProcessor } from "../utils/postcss"; import { absoluteCssUrlsPlugin } from "./absoluteCssUrlsPlugin"; const isExtendedLengthPath = /^\\\\\?\\/; @@ -18,11 +17,7 @@ function normalizePathSlashes(p: string) { * This plugin loads css files with the "css" loader (bundles and moves assets to assets directory) * and exports the url of the css file as its default export. */ -export function cssFilePlugin({ - config, - options, - fileWatchCache, -}: Context): esbuild.Plugin { +export function cssFilePlugin(ctx: Context): esbuild.Plugin { return { name: "css-file", @@ -46,7 +41,8 @@ export function cssFilePlugin({ target, } = build.initialOptions; - let postcssProcessor = await getPostcssProcessor({ config }); + // eslint-disable-next-line prefer-let/prefer-let -- Avoid needing to repeatedly check for null since const can't be reassigned + const postcssProcessor = await getCachedPostcssProcessor(ctx); build.onLoad({ filter: /\.css$/ }, async (args) => { let { metafile, outputFiles, warnings, errors } = await esbuild.build({ @@ -65,14 +61,14 @@ export function cssFilePlugin({ target, treeShaking, tsconfig, - minify: options.mode === "production", + minify: ctx.options.mode === "production", bundle: true, minifySyntax: true, metafile: true, write: false, - sourcemap: Boolean(options.sourcemap && postcssProcessor), // We only need source maps if we're processing the CSS with PostCSS + sourcemap: Boolean(ctx.options.sourcemap && postcssProcessor), // We only need source maps if we're processing the CSS with PostCSS splitting: false, - outdir: config.assetsBuildDirectory, + outdir: ctx.config.assetsBuildDirectory, entryNames: assetNames, entryPoints: [args.path], loader: { @@ -83,11 +79,18 @@ export function cssFilePlugin({ absoluteCssUrlsPlugin(), ...(postcssProcessor ? [ - postcssPlugin({ - fileWatchCache, - postcssProcessor, - options, - }), + { + name: "postcss-plugin", + async setup(build) { + build.onLoad( + { filter: /\.css$/, namespace: "file" }, + async (args) => ({ + contents: await postcssProcessor({ path: args.path }), + loader: "css", + }) + ); + }, + } satisfies esbuild.Plugin, ] : []), ], @@ -103,13 +106,13 @@ export function cssFilePlugin({ invariant(entry, "entry point not found"); let normalizedEntry = path.resolve( - config.rootDirectory, + ctx.config.rootDirectory, normalizePathSlashes(entry) ); let entryFile = outputFiles.find((file) => { return ( path.resolve( - config.rootDirectory, + ctx.config.rootDirectory, normalizePathSlashes(file.path) ) === normalizedEntry ); @@ -148,81 +151,3 @@ export function cssFilePlugin({ }, }; } - -function postcssPlugin({ - fileWatchCache, - postcssProcessor, - options, -}: { - fileWatchCache: Context["fileWatchCache"]; - postcssProcessor: Processor; - options: Context["options"]; -}): esbuild.Plugin { - return { - name: "postcss-plugin", - async setup(build) { - build.onLoad({ filter: /\.css$/, namespace: "file" }, async (args) => { - let cacheKey = `postcss-plugin?sourcemap=${options.sourcemap}&path=${args.path}`; - - let { cacheValue } = await fileWatchCache.getOrSet( - cacheKey, - async () => { - let contents = await fse.readFile(args.path, "utf-8"); - - let { css, messages } = await postcssProcessor.process(contents, { - from: args.path, - to: args.path, - map: options.sourcemap, - }); - - let fileDependencies = new Set(); - let globDependencies = new Set(); - - // Ensure the CSS file being passed to PostCSS is tracked as a - // dependency of this cache key since a change to this file should - // invalidate the cache, not just its sub-dependencies. - fileDependencies.add(args.path); - - // PostCSS plugin result objects can contain arbitrary messages returned - // from plugins. Here we look for messages that indicate a dependency - // on another file or glob. Here we target the generic dependency messages - // returned from 'postcss-import' and 'tailwindcss' plugins, but we may - // need to add more in the future depending on what other plugins do. - // More info: - // - https://postcss.org/api/#result - // - https://postcss.org/api/#message - for (let message of messages) { - if ( - message.type === "dependency" && - typeof message.file === "string" - ) { - fileDependencies.add(message.file); - continue; - } - - if ( - message.type === "dir-dependency" && - typeof message.dir === "string" && - typeof message.glob === "string" - ) { - globDependencies.add(path.join(message.dir, message.glob)); - continue; - } - } - - return { - cacheValue: css, - fileDependencies, - globDependencies, - }; - } - ); - - return { - contents: cacheValue, - loader: "css", - }; - }); - }, - }; -} diff --git a/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts b/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts index f2a944b0348..c81e575018f 100644 --- a/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts +++ b/packages/remix-dev/compiler/plugins/cssSideEffectImports.ts @@ -6,7 +6,7 @@ import { parse, type ParserOptions } from "@babel/parser"; import traverse from "@babel/traverse"; import generate from "@babel/generator"; -import { getPostcssProcessor } from "../utils/postcss"; +import { getCachedPostcssProcessor } from "../utils/postcss"; import { applyHMR } from "../js/plugins/hmr"; import type { Context } from "../context"; @@ -51,7 +51,7 @@ export const cssSideEffectImportsPlugin = ( return { name: pluginName, setup: async (build) => { - let postcssProcessor = await getPostcssProcessor(ctx); + let postcssProcessor = await getCachedPostcssProcessor(ctx); build.onLoad( { filter: allJsFilesFilter, namespace: "file" }, @@ -107,21 +107,12 @@ export const cssSideEffectImportsPlugin = ( ); build.onLoad({ filter: /\.css$/, namespace }, async (args) => { - let contents = await fse.readFile(args.path, "utf8"); - - if (postcssProcessor) { - contents = ( - await postcssProcessor.process(contents, { - from: args.path, - to: args.path, - map: ctx.options.sourcemap, - }) - ).css; - } - + let absolutePath = path.resolve(ctx.config.rootDirectory, args.path); return { - contents, - resolveDir: path.dirname(args.path), + contents: postcssProcessor + ? await postcssProcessor({ path: absolutePath }) + : await fse.readFile(absolutePath, "utf8"), + resolveDir: path.dirname(absolutePath), loader: "css", }; }); diff --git a/packages/remix-dev/compiler/plugins/vanillaExtract.ts b/packages/remix-dev/compiler/plugins/vanillaExtract.ts index 151b4732d1c..4d039699550 100644 --- a/packages/remix-dev/compiler/plugins/vanillaExtract.ts +++ b/packages/remix-dev/compiler/plugins/vanillaExtract.ts @@ -70,9 +70,7 @@ export function vanillaExtractPlugin( let postcssProcessor = await getPostcssProcessor({ config, - context: { - vanillaExtract: true, - }, + postcssContext: { vanillaExtract: true }, }); // Resolve virtual CSS files first to avoid resolving the same diff --git a/packages/remix-dev/compiler/utils/postcss.ts b/packages/remix-dev/compiler/utils/postcss.ts index 8efe0cac24f..2ac3a09a055 100644 --- a/packages/remix-dev/compiler/utils/postcss.ts +++ b/packages/remix-dev/compiler/utils/postcss.ts @@ -1,21 +1,20 @@ +import path from "path"; import { pathToFileURL } from "url"; +import * as fse from "fs-extra"; import loadConfig from "postcss-load-config"; import type { AcceptedPlugin, Processor } from "postcss"; import postcss from "postcss"; import type { RemixConfig } from "../../config"; +import type { Options } from "../options"; +import type { FileWatchCache } from "../fileWatchCache"; import { findConfig } from "../../config"; -interface Options { - config: RemixConfig; - context?: RemixPostcssContext; -} - -interface RemixPostcssContext { +interface PostcssContext { vanillaExtract: boolean; } -const defaultContext: RemixPostcssContext = { +const defaultPostcssContext: PostcssContext = { vanillaExtract: false, }; @@ -23,21 +22,30 @@ function isPostcssEnabled(config: RemixConfig) { return config.postcss || config.tailwind; } -function getCacheKey({ config, context }: Required) { - return [config.rootDirectory, context.vanillaExtract].join("|"); +function getCacheKey({ + config, + postcssContext, +}: { + config: RemixConfig; + postcssContext: PostcssContext; +}) { + return [config.rootDirectory, postcssContext.vanillaExtract].join("|"); } let pluginsCache = new Map>(); export async function loadPostcssPlugins({ config, - context = defaultContext, -}: Options): Promise> { + postcssContext = defaultPostcssContext, +}: { + config: RemixConfig; + postcssContext?: PostcssContext; +}): Promise> { if (!isPostcssEnabled(config)) { return []; } let { rootDirectory } = config; - let cacheKey = getCacheKey({ config, context }); + let cacheKey = getCacheKey({ config, postcssContext }); let cachedPlugins = pluginsCache.get(cacheKey); if (cachedPlugins) { return cachedPlugins; @@ -50,8 +58,7 @@ export async function loadPostcssPlugins({ let postcssConfig = await loadConfig( // We're nesting our custom context values in a "remix" // namespace to avoid clashing with other tools. - // @ts-expect-error Custom context values aren't type safe. - { remix: context }, + { remix: postcssContext } as loadConfig.ConfigContext, // Custom config extensions aren't type safe rootDirectory ); @@ -75,19 +82,22 @@ export async function loadPostcssPlugins({ let processorCache = new Map(); export async function getPostcssProcessor({ config, - context = defaultContext, -}: Options): Promise { + postcssContext = defaultPostcssContext, +}: { + config: RemixConfig; + postcssContext?: PostcssContext; +}): Promise { if (!isPostcssEnabled(config)) { return null; } - let cacheKey = getCacheKey({ config, context }); + let cacheKey = getCacheKey({ config, postcssContext }); let cachedProcessor = processorCache.get(cacheKey); if (cachedProcessor !== undefined) { return cachedProcessor; } - let plugins = await loadPostcssPlugins({ config, context }); + let plugins = await loadPostcssPlugins({ config, postcssContext }); let processor = plugins.length > 0 ? postcss(plugins) : null; processorCache.set(cacheKey, processor); @@ -153,3 +163,74 @@ async function loadTailwindPlugin( return tailwindPlugin; } + +export async function getCachedPostcssProcessor({ + config, + options, + fileWatchCache, +}: { + config: RemixConfig; + options: Options; + fileWatchCache: FileWatchCache; +}) { + // eslint-disable-next-line prefer-let/prefer-let -- Avoid needing to repeatedly check for null since const can't be reassigned + const postcssProcessor = await getPostcssProcessor({ config }); + + if (!postcssProcessor) { + return null; + } + + return async function processCss(args: { path: string }) { + let cacheKey = `postcss:${args.path}?sourcemap=${options.sourcemap}`; + + let { cacheValue } = await fileWatchCache.getOrSet(cacheKey, async () => { + let contents = await fse.readFile(args.path, "utf-8"); + + let { css, messages } = await postcssProcessor.process(contents, { + from: args.path, + to: args.path, + map: options.sourcemap, + }); + + let fileDependencies = new Set(); + let globDependencies = new Set(); + + // Ensure the CSS file being passed to PostCSS is tracked as a + // dependency of this cache key since a change to this file should + // invalidate the cache, not just its sub-dependencies. + fileDependencies.add(args.path); + + // PostCSS plugin result objects can contain arbitrary messages returned + // from plugins. Here we look for messages that indicate a dependency + // on another file or glob. Here we target the generic dependency messages + // returned from 'postcss-import' and 'tailwindcss' plugins, but we may + // need to add more in the future depending on what other plugins do. + // More info: + // - https://postcss.org/api/#result + // - https://postcss.org/api/#message + for (let message of messages) { + if (message.type === "dependency" && typeof message.file === "string") { + fileDependencies.add(message.file); + continue; + } + + if ( + message.type === "dir-dependency" && + typeof message.dir === "string" && + typeof message.glob === "string" + ) { + globDependencies.add(path.join(message.dir, message.glob)); + continue; + } + } + + return { + cacheValue: css, + fileDependencies, + globDependencies, + }; + }); + + return cacheValue; + }; +}