diff --git a/.changeset/hungry-owls-greet.md b/.changeset/hungry-owls-greet.md new file mode 100644 index 00000000000..3257dafd368 --- /dev/null +++ b/.changeset/hungry-owls-greet.md @@ -0,0 +1,8 @@ +--- +"@remix-run/dev": minor +"@remix-run/react": minor +"@remix-run/server-runtime": minor +"@remix-run/testing": minor +--- + +Add unstable support for CSS side-effect imports via `future.unstable_cssSideEffectImports` feature flag diff --git a/docs/file-conventions/remix-config.md b/docs/file-conventions/remix-config.md index eccd70d76e4..a37bc5b1bbe 100644 --- a/docs/file-conventions/remix-config.md +++ b/docs/file-conventions/remix-config.md @@ -132,7 +132,7 @@ The `serverBuildTarget` can be one of the following: ## serverDependenciesToBundle -A list of regex patterns that determines if a module is transpiled and included in the server bundle. This can be useful when consuming ESM only packages in a CJS build. +A list of regex patterns that determines if a module is transpiled and included in the server bundle. This can be useful when consuming ESM only packages in a CJS build, or when consuming packages with [CSS side-effect imports][css-side-effect-imports]. For example, the `unified` ecosystem is all ESM-only. Let's also say we're using a `@sindresorhus/slugify` which is ESM-only as well. Here's how you would be able to consume those packages in a CJS app without having to use dynamic imports: @@ -186,3 +186,4 @@ There are a few conventions that Remix uses you should be aware of. [an-awesome-visualization]: https://remix-routing-demo.netlify.app [remix-dev]: ../other-api/dev#remix-dev [app-directory]: #appDirectory +[css-side-effect-imports]: ../guides/styling#css-side-effect-imports diff --git a/docs/guides/styling.md b/docs/guides/styling.md index 07171e72872..a84c1414217 100644 --- a/docs/guides/styling.md +++ b/docs/guides/styling.md @@ -766,23 +766,13 @@ Other CSS-in-JS libraries will have a similar setup. If you've got a CSS framewo NOTE: You may run into hydration warnings when using Styled Components. Hopefully [this issue][styled-components-issue] will be fixed soon. -## CSS Modules +## CSS Bundling -This feature is unstable and currently only available behind a feature flag. We're confident in the use cases it solves but the API and implementation may change in the future. +CSS bundling features are unstable and currently only available behind feature flags. We're confident in the use cases they solve but the API and implementation may change in the future. -To enable [CSS Modules], set the `future.unstable_cssModules` feature flag in `remix.config.js`. +Many common approaches to CSS within the React community are only possible when bundling CSS, meaning that the CSS files you write during development are collected into a separate bundle as part of the build process. -```js filename=remix.config.js -/** @type {import('@remix-run/dev').AppConfig} */ -module.exports = { - future: { - unstable_cssModules: true, - }, - // ... -}; -``` - -With this feature flag enabled, the Remix compiler will now generate a single CSS file containing all CSS Modules styles in your application. Note that any [regular stylesheet imports][regular-stylesheet-imports] will remain as separate files. +When using CSS bundling features, the Remix compiler will generate a single CSS file containing all bundled styles in your application. Note that any [regular stylesheet imports](#regular-stylesheets) will remain as separate files. Unlike many other tools in the React ecosystem, we do not insert the CSS bundle into the page automatically. Instead, we ensure that you always have control over the link tags on your page. This lets you decide where the CSS file is loaded relative to other stylesheets in your app. @@ -806,7 +796,27 @@ export const links: LinksFunction = () => { }; ``` -You're all set! You can now opt into CSS Modules via the `.module.css` file name convention. For example: +With this link tag inserted into the page, you're now ready to start using the various CSS bundling features built into Remix. + +### CSS Modules + +This feature is unstable and currently only available behind a feature flag. We're confident in the use cases it solves but the API and implementation may change in the future. + +First, ensure you've set up [CSS bundling](#css-bundling) in your application. + +Then, to enable [CSS Modules], set the `future.unstable_cssModules` feature flag in `remix.config.js`. + +```js filename=remix.config.js +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + future: { + unstable_cssModules: true, + }, + // ... +}; +``` + +With this feature flag enabled, you can now opt into CSS Modules via the `.module.css` file name convention. For example: ```css filename=app/components/button/styles.module.css .root { @@ -833,6 +843,41 @@ export const Button = React.forwardRef( Button.displayName = "Button"; ``` +### CSS Side-Effect Imports + +This feature is unstable and currently only available behind a feature flag. We're confident in the use cases it solves but the API and implementation may change in the future. + +Some NPM packages use side-effect imports of plain CSS files (e.g. `import "./styles.css"`) to declare the CSS dependencies of JavaScript files. If you want to consume one of these packages, first ensure you've set up [CSS bundling](#css-bundling) in your application. + +Then, set the `future.unstable_cssSideEffectImports` feature flag in `remix.config.js`. + +```js filename=remix.config.js +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + future: { + unstable_cssSideEffectImports: true, + }, + // ... +}; +``` + +Finally, since JavaScript runtimes don't support importing CSS in this way, you'll also need to add any relevant packages to the [`serverDependenciesToBundle`][server-dependencies-to-bundle] option in your `remix.config.js` file. This ensures that any CSS imports are compiled out of your code before running it on the server. For example, to use React Spectrum: + +```js filename=remix.config.js +/** @type {import('@remix-run/dev').AppConfig} */ +module.exports = { + serverDependenciesToBundle: [ + /^@adobe\/react-spectrum/, + /^@react-spectrum/, + /^@spectrum-icons/, + ], + future: { + unstable_cssSideEffectImports: true, + }, + // ... +}; +``` + [custom-properties]: https://developer.mozilla.org/en-US/docs/Web/CSS/--* [link]: ../components/link [route-module-links]: ../route/links @@ -843,3 +888,4 @@ Button.displayName = "Button"; [tailwind-intelli-sense-extension]: https://marketplace.visualstudio.com/items?itemName=bradlc.vscode-tailwindcss [css modules]: https://github.com/css-modules/css-modules [regular-stylesheet-imports]: #regular-stylesheets +[server-dependencies-to-bundle]: ../file-conventions/remix-config#serverdependenciestobundle diff --git a/integration/css-modules-test.ts b/integration/css-modules-test.ts index 5cc8a2d1c14..ecba714980e 100644 --- a/integration/css-modules-test.ts +++ b/integration/css-modules-test.ts @@ -23,7 +23,10 @@ test.describe("CSS Modules", () => { "remix.config.js": js` module.exports = { future: { + // Enable all CSS future flags to + // ensure features don't clash unstable_cssModules: true, + unstable_cssSideEffectImports: true, }, }; `, diff --git a/integration/css-side-effect-imports-test.ts b/integration/css-side-effect-imports-test.ts new file mode 100644 index 00000000000..fc4c0997382 --- /dev/null +++ b/integration/css-side-effect-imports-test.ts @@ -0,0 +1,273 @@ +import { test, expect } from "@playwright/test"; + +import { PlaywrightFixture } from "./helpers/playwright-fixture"; +import type { Fixture, AppFixture } from "./helpers/create-fixture"; +import { + createAppFixture, + createFixture, + css, + js, +} from "./helpers/create-fixture"; + +const TEST_PADDING_VALUE = "20px"; + +test.describe("CSS side-effect imports", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "remix.config.js": js` + module.exports = { + serverDependenciesToBundle: [/@test-package/], + future: { + // Enable all CSS future flags to + // ensure features don't clash + unstable_cssModules: true, + unstable_cssSideEffectImports: true, + }, + }; + `, + "app/root.jsx": js` + import { Links, Outlet } from "@remix-run/react"; + import { cssBundleHref } from "@remix-run/css-bundle"; + export function links() { + return [{ rel: "stylesheet", href: cssBundleHref }]; + } + export default function Root() { + return ( + + + + + + + + + ) + } + `, + ...basicSideEffectFixture(), + ...rootRelativeFixture(), + ...imageUrlsFixture(), + ...rootRelativeImageUrlsFixture(), + ...commonJsPackageFixture(), + ...esmPackageFixture(), + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(async () => { + await appFixture.close(); + }); + + let basicSideEffectFixture = () => ({ + "app/basicSideEffect/styles.css": css` + .basicSideEffect { + background: peachpuff; + padding: ${TEST_PADDING_VALUE}; + } + `, + "app/routes/basic-side-effect-test.jsx": js` + import "../basicSideEffect/styles.css"; + + export default function() { + return ( +
+ Basic side effect test +
+ ) + } + `, + }); + test("basic side effect", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/basic-side-effect-test"); + let locator = await page.locator("[data-testid='basic-side-effect']"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let rootRelativeFixture = () => ({ + "app/rootRelative/styles.css": css` + .rootRelative { + background: peachpuff; + padding: ${TEST_PADDING_VALUE}; + } + `, + "app/routes/root-relative-test.jsx": js` + import "~/rootRelative/styles.css"; + + export default function() { + return ( +
+ Root relative import test +
+ ) + } + `, + }); + test("root relative", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/root-relative-test"); + let locator = await page.locator("[data-testid='root-relative']"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let imageUrlsFixture = () => ({ + "app/imageUrls/styles.css": css` + .imageUrls { + background-color: peachpuff; + background-image: url(./image.svg); + padding: ${TEST_PADDING_VALUE}; + } + `, + "app/imageUrls/image.svg": ` + + + + `, + "app/routes/image-urls-test.jsx": js` + import "../imageUrls/styles.css"; + + export default function() { + return ( +
+ Image URLs test +
+ ) + } + `, + }); + test("image URLs", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/image-urls-test"); + let locator = await page.locator("[data-testid='image-urls']"); + let backgroundImage = await locator.evaluate( + (element) => window.getComputedStyle(element).backgroundImage + ); + expect(backgroundImage).toContain(".svg"); + }); + + let rootRelativeImageUrlsFixture = () => ({ + "app/rootRelativeImageUrls/styles.css": css` + .rootRelativeImageUrls { + background-color: peachpuff; + background-image: url(~/rootRelativeImageUrls/image.svg); + padding: ${TEST_PADDING_VALUE}; + } + `, + "app/rootRelativeImageUrls/image.svg": ` + + + + `, + "app/routes/root-relative-image-urls-test.jsx": js` + import "../rootRelativeImageUrls/styles.css"; + + export default function() { + return ( +
+ Image URLs test +
+ ) + } + `, + }); + test("root relative image URLs", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/root-relative-image-urls-test"); + let locator = await page.locator( + "[data-testid='root-relative-image-urls']" + ); + let backgroundImage = await locator.evaluate( + (element) => window.getComputedStyle(element).backgroundImage + ); + expect(backgroundImage).toContain(".svg"); + }); + + let commonJsPackageFixture = () => ({ + "node_modules/@test-package/commonjs/styles.css": css` + .commonJsPackage { + background: peachpuff; + padding: ${TEST_PADDING_VALUE}; + } + `, + "node_modules/@test-package/commonjs/index.js": js` + var React = require('react'); + require('./styles.css'); + + exports.Test = function() { + return React.createElement( + 'div', + { + 'data-testid': 'commonjs-package', + 'className': 'commonJsPackage' + }, + 'CommonJS package test', + ); + }; + `, + "app/routes/commonjs-package-test.jsx": js` + import { Test } from "@test-package/commonjs"; + export default function() { + return ; + } + `, + }); + test("CommonJS package", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/commonjs-package-test"); + let locator = await page.locator("[data-testid='commonjs-package']"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); + + let esmPackageFixture = () => ({ + "node_modules/@test-package/esm/styles.css": css` + .esmPackage { + background: peachpuff; + padding: ${TEST_PADDING_VALUE}; + } + `, + "node_modules/@test-package/esm/index.js": js` + import React from 'react'; + import './styles.css'; + + export function Test() { + return React.createElement( + 'div', + { + 'data-testid': 'esm-package', + 'className': 'esmPackage' + }, + 'ESM package test', + ); + }; + `, + "app/routes/esm-package-test.jsx": js` + import { Test } from "@test-package/esm"; + export default function() { + return ; + } + `, + }); + test("ESM package", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/esm-package-test"); + let locator = await page.locator("[data-testid='esm-package']"); + let padding = await locator.evaluate( + (element) => window.getComputedStyle(element).padding + ); + expect(padding).toBe(TEST_PADDING_VALUE); + }); +}); diff --git a/integration/deterministic-build-output-test.ts b/integration/deterministic-build-output-test.ts index e0b6fe54d74..0e47ebbe6e2 100644 --- a/integration/deterministic-build-output-test.ts +++ b/integration/deterministic-build-output-test.ts @@ -17,6 +17,7 @@ test("builds deterministically under different paths", async () => { // * browserRouteModulesPlugin (implicitly tested by root route) // * cssEntryModulePlugin (implicitly tested by build) // * cssModulesPlugin (via app/routes/foo.tsx' CSS Modules import) + // * cssSideEffectImportsPlugin (via app/routes/foo.tsx' CSS side-effect import) // * emptyModulesPlugin (via app/routes/foo.tsx' server import) // * mdx (via app/routes/index.mdx) // * serverAssetsManifestPlugin (implicitly tested by build) @@ -28,6 +29,7 @@ test("builds deterministically under different paths", async () => { module.exports = { future: { unstable_cssModules: true, + unstable_cssSideEffectImports: true, }, }; `, @@ -35,6 +37,7 @@ test("builds deterministically under different paths", async () => { "app/routes/foo.tsx": js` export * from "~/foo/bar.server"; import styles from "~/styles/foo.module.css"; + import "~/styles/side-effect.css"; export default () =>
YAY
; `, "app/foo/bar.server.ts": "export const meta = () => []", @@ -60,6 +63,11 @@ test("builds deterministically under different paths", async () => { `, + "app/styles/side-effect.css": css` + .side-effect { + color: mintcream; + } + `, }, }; let dir1 = await createFixtureProject(init); diff --git a/packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts b/packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts new file mode 100644 index 00000000000..7c67d4a031f --- /dev/null +++ b/packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts @@ -0,0 +1,225 @@ +import dedent from "dedent"; + +import { addSuffixToCssSideEffectImports } from "../compiler/plugins/cssSideEffectImportsPlugin"; + +describe("addSuffixToCssSideEffectImports", () => { + describe("adds suffix", () => { + test("side-effect require", () => { + let code = dedent` + require("./foo.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"require(\\"./foo.css?__remix_sideEffect__\\");"` + ); + }); + + test("side-effect import", () => { + let code = dedent` + import "./foo.css"; + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"import \\"./foo.css?__remix_sideEffect__\\";"` + ); + }); + + test("side-effect import with JSX", () => { + let code = dedent` + import "./foo.css"; + + export const Foo = () =>
; + `; + + expect(addSuffixToCssSideEffectImports("jsx", code)) + .toMatchInlineSnapshot(` + "import \\"./foo.css?__remix_sideEffect__\\"; + + export const Foo = () =>
;" + `); + }); + + test("side-effect import in TypeScript", () => { + let code = dedent` + require("./foo.css"); + + export const foo: string = 'foo' satisfies string; + `; + + expect(addSuffixToCssSideEffectImports("ts", code)) + .toMatchInlineSnapshot(` + "require(\\"./foo.css?__remix_sideEffect__\\"); + + export const foo: string = ('foo' satisfies string);" + `); + }); + + test("side-effect import in TypeScript with JSX", () => { + let code = dedent` + require("./foo.css"); + + export const foo: string = 'foo' satisfies string; + export const Bar = () =>
{foo}
; + `; + + expect(addSuffixToCssSideEffectImports("tsx", code)) + .toMatchInlineSnapshot(` + "require(\\"./foo.css?__remix_sideEffect__\\"); + + export const foo: string = ('foo' satisfies string); + export const Bar = () =>
{foo}
;" + `); + }); + + test("conditional side-effect require", () => { + let code = dedent` + if (process.env.NODE_ENV === "production") { + require("./foo.min.css"); + } else { + require("./foo.css"); + } + `; + + expect(addSuffixToCssSideEffectImports("js", code)) + .toMatchInlineSnapshot(` + "if (process.env.NODE_ENV === \\"production\\") { + require(\\"./foo.min.css?__remix_sideEffect__\\"); + } else { + require(\\"./foo.css?__remix_sideEffect__\\"); + }" + `); + }); + + test("conditional side-effect require via ternary", () => { + let code = dedent` + process.env.NODE_ENV === "production" ? require("./foo.min.css") : require("./foo.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"process.env.NODE_ENV === \\"production\\" ? require(\\"./foo.min.css?__remix_sideEffect__\\") : require(\\"./foo.css?__remix_sideEffect__\\");"` + ); + }); + + test("conditional side-effect require via && operator", () => { + let code = dedent` + process.env.NODE_ENV === "development" && require("./debug.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"process.env.NODE_ENV === \\"development\\" && require(\\"./debug.css?__remix_sideEffect__\\");"` + ); + }); + + test("conditional side-effect require via || operator", () => { + let code = dedent` + process.env.NODE_ENV === "production" || require("./debug.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"process.env.NODE_ENV === \\"production\\" || require(\\"./debug.css?__remix_sideEffect__\\");"` + ); + }); + }); + + describe("doesn't add suffix", () => { + test("ignores non side-effect require of CSS", () => { + let code = dedent` + const href = require("./foo.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"const href = require(\\"./foo.css\\");"` + ); + }); + + test("ignores default import of CSS", () => { + let code = dedent` + import href from "./foo.css"; + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"import href from \\"./foo.css\\";"` + ); + }); + + test("ignores named import of CSS", () => { + let code = dedent` + import { foo } from "./foo.css"; + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"import { foo } from \\"./foo.css\\";"` + ); + }); + + test("ignores namespace import of CSS", () => { + let code = dedent` + import * as foo from "./foo.css"; + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"import * as foo from \\"./foo.css\\";"` + ); + }); + + test("ignores conditional non side-effect require of CSS", () => { + let code = dedent` + const href = process.env.NODE_ENV === "production" ? + require("./foo.min.css") : + require("./foo.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)) + .toMatchInlineSnapshot(` + "const href = process.env.NODE_ENV === \\"production\\" ? + require(\\"./foo.min.css\\") : + require(\\"./foo.css\\");" + `); + }); + + test("ignores conditional non side-effect require of CSS via logical operators", () => { + let code = dedent` + const href = (process.env.NODE_ENV === "production" && require("./foo.min.css")) || require("./foo.css"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"const href = process.env.NODE_ENV === \\"production\\" && require(\\"./foo.min.css\\") || require(\\"./foo.css\\");"` + ); + }); + + test("ignores side-effect require of non-CSS", () => { + let code = dedent` + require("./foo"); + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"require(\\"./foo\\");"` + ); + }); + + test("ignores side-effect import of non-CSS", () => { + let code = dedent` + import "./foo"; + `; + + expect(addSuffixToCssSideEffectImports("js", code)).toMatchInlineSnapshot( + `"import \\"./foo\\";"` + ); + }); + + test("ignores dynamic import", () => { + let code = dedent` + export const foo = async () => { + await import("./foo.css"); + } + `; + + expect(addSuffixToCssSideEffectImports("js", code)) + .toMatchInlineSnapshot(` + "export const foo = async () => { + await import(\\"./foo.css\\"); + };" + `); + }); + }); +}); diff --git a/packages/remix-dev/__tests__/readConfig-test.ts b/packages/remix-dev/__tests__/readConfig-test.ts index 2c0c77490c2..1b79226fb30 100644 --- a/packages/remix-dev/__tests__/readConfig-test.ts +++ b/packages/remix-dev/__tests__/readConfig-test.ts @@ -24,6 +24,7 @@ describe("readConfig", () => { tsconfigPath: expect.any(String), future: { unstable_cssModules: expect.any(Boolean), + unstable_cssSideEffectImports: expect.any(Boolean), v2_meta: expect.any(Boolean), }, }, @@ -38,6 +39,7 @@ describe("readConfig", () => { "entryServerFile": "entry.server.tsx", "future": Object { "unstable_cssModules": Any, + "unstable_cssSideEffectImports": Any, "v2_meta": Any, }, "mdx": undefined, diff --git a/packages/remix-dev/compiler/compileBrowser.ts b/packages/remix-dev/compiler/compileBrowser.ts index fe45679bfed..c6b38d2f913 100644 --- a/packages/remix-dev/compiler/compileBrowser.ts +++ b/packages/remix-dev/compiler/compileBrowser.ts @@ -20,6 +20,7 @@ import { emptyModulesPlugin } from "./plugins/emptyModulesPlugin"; import { mdxPlugin } from "./plugins/mdx"; import { urlImportsPlugin } from "./plugins/urlImportsPlugin"; import { cssModulesPlugin } from "./plugins/cssModulesPlugin"; +import { cssSideEffectImportsPlugin } from "./plugins/cssSideEffectImportsPlugin"; import { cssBundleEntryModulePlugin, cssBundleEntryModuleId, @@ -66,6 +67,10 @@ const writeAssetsManifest = async ( ); }; +const isCssBundlingEnabled = (config: RemixConfig) => + config.future.unstable_cssModules || + config.future.unstable_cssSideEffectImports; + const createEsbuildConfig = ( build: "app" | "css", config: RemixConfig, @@ -91,28 +96,26 @@ const createEsbuildConfig = ( } } + let { mode } = options; + let { rootDirectory } = config; let plugins: esbuild.Plugin[] = [ deprecatedRemixPackagePlugin(options.onWarning), - ...(config.future.unstable_cssModules - ? [ - ...(isCssBuild ? [cssBundleEntryModulePlugin(config)] : []), - cssModulesPlugin({ - mode: options.mode, - rootDirectory: config.rootDirectory, - outputCss: isCssBuild, - }), - ] - : []), - cssFilePlugin({ - mode: options.mode, - rootDirectory: config.rootDirectory, - }), + isCssBundlingEnabled(config) && isCssBuild + ? cssBundleEntryModulePlugin(config) + : null, + config.future.unstable_cssModules + ? cssModulesPlugin({ mode, rootDirectory, outputCss: isCssBuild }) + : null, + config.future.unstable_cssSideEffectImports + ? cssSideEffectImportsPlugin({ rootDirectory }) + : null, + cssFilePlugin({ mode, rootDirectory }), urlImportsPlugin(), mdxPlugin(config), browserRouteModulesPlugin(config, /\?browser$/), emptyModulesPlugin(config, /\.server(\.[jt]sx?)?$/), NodeModulesPolyfillPlugin(), - ]; + ].filter(isNotNull); return { entryPoints, @@ -172,7 +175,7 @@ export const createBrowserCompiler = ( }; let cssBuildTask = async () => { - if (!remixConfig.future.unstable_cssModules) { + if (!isCssBundlingEnabled(remixConfig)) { return; } @@ -267,3 +270,7 @@ export const createBrowserCompiler = ( }, }; }; + +function isNotNull(value: Value): value is Exclude { + return value !== null; +} diff --git a/packages/remix-dev/compiler/compilerServer.ts b/packages/remix-dev/compiler/compilerServer.ts index 8147e1405ac..7040e315bad 100644 --- a/packages/remix-dev/compiler/compilerServer.ts +++ b/packages/remix-dev/compiler/compilerServer.ts @@ -9,6 +9,7 @@ import type { AssetsManifest } from "./assets"; import { loaders } from "./loaders"; import type { CompileOptions } from "./options"; import { cssModulesPlugin } from "./plugins/cssModulesPlugin"; +import { cssSideEffectImportsPlugin } from "./plugins/cssSideEffectImportsPlugin"; import { cssFilePlugin } from "./plugins/cssFilePlugin"; import { deprecatedRemixPackagePlugin } from "./plugins/deprecatedRemixPackagePlugin"; import { emptyModulesPlugin } from "./plugins/emptyModulesPlugin"; @@ -48,21 +49,17 @@ const createEsbuildConfig = ( ); let isDenoRuntime = config.serverBuildTarget === "deno"; + let { mode } = options; + let { rootDirectory } = config; let plugins: esbuild.Plugin[] = [ deprecatedRemixPackagePlugin(options.onWarning), - ...(config.future.unstable_cssModules - ? [ - cssModulesPlugin({ - mode: options.mode, - rootDirectory: config.rootDirectory, - outputCss: false, - }), - ] - : []), - cssFilePlugin({ - mode: options.mode, - rootDirectory: config.rootDirectory, - }), + config.future.unstable_cssModules + ? cssModulesPlugin({ mode, rootDirectory, outputCss: false }) + : null, + config.future.unstable_cssSideEffectImports + ? cssSideEffectImportsPlugin({ rootDirectory }) + : null, + cssFilePlugin({ mode, rootDirectory }), urlImportsPlugin(), mdxPlugin(config), emptyModulesPlugin(config, /\.client(\.[jt]sx?)?$/), @@ -70,7 +67,7 @@ const createEsbuildConfig = ( serverEntryModulePlugin(config), serverAssetsManifestPlugin(assetsManifestChannel.read()), serverBareModulesPlugin(config, options.onWarning), - ]; + ].filter(isNotNull); if (config.serverPlatform !== "node") { plugins.unshift(NodeModulesPolyfillPlugin()); @@ -192,3 +189,7 @@ export const createServerCompiler = ( dispose: () => undefined, }; }; + +function isNotNull(value: Value): value is Exclude { + return value !== null; +} diff --git a/packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts b/packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts new file mode 100644 index 00000000000..662fbb6503f --- /dev/null +++ b/packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts @@ -0,0 +1,170 @@ +import path from "path"; +import type { Plugin } from "esbuild"; +import fse from "fs-extra"; +import LRUCache from "lru-cache"; +import { parse, type ParserOptions } from "@babel/parser"; +import traverse from "@babel/traverse"; +import generate from "@babel/generator"; + +const pluginName = "css-side-effects-plugin"; +const namespace = `${pluginName}-ns`; +const cssSideEffectSuffix = "?__remix_sideEffect__"; +const cssSideEffectFilter = new RegExp( + `\\.css${cssSideEffectSuffix.replace("?", "\\?")}$` +); + +export function isCssSideEffectImportPath(path: string): boolean { + return cssSideEffectFilter.test(path); +} + +const loaders = ["js", "jsx", "ts", "tsx"] as const; +const allJsFilesFilter = new RegExp(`\\.(${loaders.join("|")})$`); + +type Loader = typeof loaders[number]; +type Extension = `.${Loader}`; + +const loaderForExtension: Record = { + ".js": "js", + ".jsx": "jsx", + ".ts": "ts", + ".tsx": "tsx", +}; + +/** + * This plugin detects side-effect imports of CSS files and adds a suffix + * to the import path, e.g. `import "./styles.css"` is transformed to + * `import "./styles.css?__remix_sideEffect__"`). This allows them to be + * differentiated from non-side-effect imports so that they can be added + * to the CSS bundle. This is primarily designed to support packages that + * import plain CSS files directly within JS files. + */ +export const cssSideEffectImportsPlugin = (options: { + rootDirectory: string; +}): Plugin => { + return { + name: pluginName, + setup: async (build) => { + build.onLoad( + { filter: allJsFilesFilter, namespace: "file" }, + async (args) => { + let code = await fse.readFile(args.path, "utf8"); + + // Don't process file if it doesn't contain any references to CSS files + if (!code.includes(".css")) { + return null; + } + + let loader = loaderForExtension[path.extname(args.path) as Extension]; + let contents = addSuffixToCssSideEffectImports(loader, code); + + return { + contents, + loader, + }; + } + ); + + build.onResolve( + { filter: cssSideEffectFilter, namespace: "file" }, + async (args) => { + let resolvedPath = ( + await build.resolve(args.path, { + resolveDir: args.resolveDir, + kind: args.kind, + }) + ).path; + + return { + path: path.relative(options.rootDirectory, resolvedPath), + namespace: resolvedPath.endsWith(".css") ? namespace : undefined, + }; + } + ); + + build.onLoad({ filter: /\.css$/, namespace }, async (args) => { + let contents = await fse.readFile(args.path, "utf8"); + + return { + contents, + resolveDir: path.dirname(args.path), + loader: "css", + }; + }); + }, + }; +}; + +const babelPluginsForLoader: Record = { + js: [], + jsx: ["jsx"], + ts: ["typescript"], + tsx: ["typescript", "jsx"], +}; + +const cache = new LRUCache({ max: 1000 }); +const getCacheKey = (loader: Loader, code: string) => `${loader}:${code}`; + +export function addSuffixToCssSideEffectImports( + loader: Loader, + code: string +): string { + let cacheKey = getCacheKey(loader, code); + let cachedResult = cache.get(cacheKey); + + if (cachedResult) { + return cachedResult; + } + + let ast = parse(code, { + sourceType: "module", + plugins: babelPluginsForLoader[loader], + }); + + traverse(ast, { + // Handle `import "./styles.css"` + ImportDeclaration(path) { + if ( + path.node.specifiers.length === 0 && // i.e. nothing was imported + path.node.source.value.endsWith(".css") + ) { + path.node.source.value += cssSideEffectSuffix; + } + }, + + // Handle `require("./styles.css")` + CallExpression(path) { + if ( + path.node.callee.type === "Identifier" && + path.node.callee.name === "require" && + // Require call must be its own statement, + // not nested within another expression, + (path.parent.type === "ExpressionStatement" || + // or, the statement must only consist of a + // ternary or logical expression, without + // assigning the result to a variable. + ((path.parent.type === "ConditionalExpression" || + path.parent.type === "LogicalExpression") && + path.parentPath.parent.type === "ExpressionStatement")) + ) { + let specifier = path.node.arguments[0]; + + if ( + specifier && + specifier.type === "StringLiteral" && + specifier.value.endsWith(".css") + ) { + specifier.value += cssSideEffectSuffix; + } + } + }, + }); + + let result = generate(ast, { + retainLines: true, + compact: false, + }).code; + + cache.set(cacheKey, result); + + return result; +} diff --git a/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts b/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts index 3cb5135181b..e2c840cd817 100644 --- a/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts +++ b/packages/remix-dev/compiler/plugins/serverBareModulesPlugin.ts @@ -9,6 +9,7 @@ import { serverBuildVirtualModule, assetsManifestVirtualModule, } from "../virtualModules"; +import { isCssSideEffectImportPath } from "./cssSideEffectImportsPlugin"; import { createMatchPath } from "../utils/tsconfig"; import { getPreferredPackageManager } from "../../cli/getPreferredPackageManager"; @@ -70,6 +71,11 @@ export function serverBareModulesPlugin( return undefined; } + // Always bundle CSS side-effect imports. + if (isCssSideEffectImportPath(path)) { + return undefined; + } + let packageName = getNpmPackageName(path); let pkgManager = getPreferredPackageManager(); diff --git a/packages/remix-dev/config.ts b/packages/remix-dev/config.ts index 797b909c726..63483c84283 100644 --- a/packages/remix-dev/config.ts +++ b/packages/remix-dev/config.ts @@ -33,6 +33,7 @@ export type ServerPlatform = "node" | "neutral"; interface FutureConfig { unstable_cssModules: boolean; + unstable_cssSideEffectImports: boolean; v2_meta: boolean; } @@ -483,6 +484,8 @@ export async function readConfig( let future = { unstable_cssModules: appConfig.future?.unstable_cssModules === true, + unstable_cssSideEffectImports: + appConfig.future?.unstable_cssSideEffectImports === true, v2_meta: appConfig.future?.v2_meta === true, }; diff --git a/packages/remix-dev/package.json b/packages/remix-dev/package.json index f7d27ba3393..c5a64c120e4 100644 --- a/packages/remix-dev/package.json +++ b/packages/remix-dev/package.json @@ -19,10 +19,13 @@ }, "dependencies": { "@babel/core": "^7.18.6", + "@babel/generator": "^7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/plugin-syntax-typescript": "^7.20.0", + "@babel/parser": "^7.18.6", "@babel/preset-env": "^7.18.6", "@babel/preset-typescript": "^7.18.6", + "@babel/traverse": "^7.18.6", "@babel/types": "^7.20.2", "@esbuild-plugins/node-modules-polyfill": "^0.1.4", "@npmcli/package-json": "^2.0.0", @@ -46,6 +49,7 @@ "json5": "^2.2.1", "lodash": "^4.17.21", "lodash.debounce": "^4.0.8", + "lru-cache": "^7.14.1", "minimatch": "^3.0.4", "node-fetch": "^2.6.7", "ora": "^5.4.1", @@ -77,6 +81,7 @@ "@types/shelljs": "^0.8.11", "@types/tar-fs": "^2.0.1", "@types/ws": "^7.4.1", + "dedent": "^0.7.0", "esbuild-register": "^3.3.2", "msw": "^0.39.2", "shelljs": "^0.8.5", diff --git a/packages/remix-react/entry.ts b/packages/remix-react/entry.ts index f3c0be0df62..79f489e0cdb 100644 --- a/packages/remix-react/entry.ts +++ b/packages/remix-react/entry.ts @@ -19,6 +19,7 @@ export interface EntryContext extends RemixContextObject { export interface FutureConfig { unstable_cssModules: boolean; + unstable_cssSideEffectImports: boolean; v2_meta: boolean; } diff --git a/packages/remix-server-runtime/entry.ts b/packages/remix-server-runtime/entry.ts index 8510afe5d6e..af0958b48f7 100644 --- a/packages/remix-server-runtime/entry.ts +++ b/packages/remix-server-runtime/entry.ts @@ -13,6 +13,7 @@ export interface EntryContext { export interface FutureConfig { unstable_cssModules: true; + unstable_cssSideEffectImports: boolean; v2_meta: boolean; } diff --git a/packages/remix-testing/create-remix-stub.tsx b/packages/remix-testing/create-remix-stub.tsx index 9066471f5f5..8cb3aeeb6d4 100644 --- a/packages/remix-testing/create-remix-stub.tsx +++ b/packages/remix-testing/create-remix-stub.tsx @@ -157,6 +157,7 @@ function createRemixContext( }, future: { unstable_cssModules: false, + unstable_cssSideEffectImports: false, v2_meta: false, ...future, }, diff --git a/yarn.lock b/yarn.lock index 6267adb4242..e7cc7683b8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -132,6 +132,15 @@ "@jridgewell/gen-mapping" "^0.3.2" jsesc "^2.5.1" +"@babel/generator@^7.18.6": + version "7.20.5" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.20.5.tgz#cb25abee3178adf58d6814b68517c62bdbfdda95" + integrity sha512-jl7JY2Ykn9S0yj4DQP82sYvPU+T3g0HFcWTqDLqiuA9tGRNIj9VfbtXGAYTTkyNEnQk1jkMGOdYka8aG/lulCA== + dependencies: + "@babel/types" "^7.20.5" + "@jridgewell/gen-mapping" "^0.3.2" + jsesc "^2.5.1" + "@babel/generator@^7.19.0": version "7.19.0" resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.19.0.tgz" @@ -432,6 +441,11 @@ resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.20.2.tgz" integrity sha512-afk318kh2uKbo7BEj2QtEi8HVCGrwHUffrYDy7dgVcSa2j9lY3LDjPzcyGdpX7xgm35aWqvciZJ4WKmdF/SxYg== +"@babel/parser@^7.18.6": + version "7.20.5" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.20.5.tgz#7f3c7335fe417665d929f34ae5dceae4c04015e8" + integrity sha512-r27t/cy/m9uKLXQNWWebeCUHgnAZq0CpG1OwKRxzJMP1vpSU4bSIK2hq+/cp0bQxetkXx38n09rNu8jVkcK/zA== + "@babel/parser@^7.19.1": version "7.19.1" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.19.1.tgz" @@ -1250,6 +1264,15 @@ "@babel/helper-validator-identifier" "^7.19.1" to-fast-properties "^2.0.0" +"@babel/types@^7.20.5": + version "7.20.5" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.20.5.tgz#e206ae370b5393d94dfd1d04cd687cace53efa84" + integrity sha512-c9fst/h2/dcF7H+MJKZ2T0KjEQ8hY/BNnDk/H3XY8C4Aw/eWQXWn/lWntHF9ooUBnGmEvbfGrTgLWc+um0YDUg== + dependencies: + "@babel/helper-string-parser" "^7.19.4" + "@babel/helper-validator-identifier" "^7.19.1" + to-fast-properties "^2.0.0" + "@bcoe/v8-coverage@^0.2.3": version "0.2.3" resolved "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz" @@ -8604,6 +8627,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^7.14.1: + version "7.14.1" + resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-7.14.1.tgz#8da8d2f5f59827edb388e63e459ac23d6d408fea" + integrity sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA== + lz-string@^1.4.4: version "1.4.4" resolved "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz"