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;
+ };
+}