Repository: remix-run/remix
Pull request #5305 (2023-01-29 23:07:13)

fix: support decorators with CSS side-effects
Closes: #5287

- [ ] Docs
- [x] Tests

Testing Strategy:

I've added snapshot tests that exercise a bunch of language features for all combinations of JS/TS with and without JSX. I wanted to make sure there weren't other obvious cases we weren't handling, but I also wanted to provide a place to add further test cases in the future.

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: b3590877cf75f2ec51f2e872a3da07cdad2a1a76

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 18 packages</summary>

  | Name                          | Type  |
| ----------------------------- | ----- |
| @remix-run/dev                | Patch |
| create-remix                  | Patch |
| @remix-run/css-bundle         | Patch |
| remix                         | Patch |
| @remix-run/architect          | Patch |
| @remix-run/cloudflare         | Patch |
| @remix-run/cloudflare-pages   | Patch |
| @remix-run/cloudflare-workers | Patch |
| @remix-run/deno               | Patch |
| @remix-run/eslint-config      | Patch |
| @remix-run/express            | Patch |
| @remix-run/netlify            | Patch |
| @remix-run/node               | Patch |
| @remix-run/react              | Patch |
| @remix-run/serve              | Patch |
| @remix-run/server-runtime     | Patch |
| @remix-run/testing            | Patch |
| @remix-run/vercel             | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/remix-run/remix/new/markdalgleish/css-side-effects-decorators?filename=.changeset/happy-mirrors-march.md&value=---%0A%22remix%22%3A%20patch%0A%22%40remix-run%2Fdev%22%3A%20patch%0A---%0A%0Afix%3A%20support%20decorators%20with%20CSS%20side-effects%0A)


markdalgleish: @MichaelDeBoey Thanks for the review!

Patch:
diff --git a/.changeset/honest-crabs-sort.md b/.changeset/honest-crabs-sort.md
new file mode 100644
index 000000000..448b7a13e
--- /dev/null
+++ b/.changeset/honest-crabs-sort.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/dev": patch
+---
+
+Support decorators in files using CSS side-effect imports
diff --git a/packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts b/packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts
index 6463c2d7b..57ff4653d 100644
--- a/packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts
+++ b/packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts
@@ -121,11 +121,13 @@ export const cssSideEffectImportsPlugin = ({
   };
 };
 
+const additionalLanguageFeatures: ParserOptions["plugins"] = ["decorators"];
+
 const babelPluginsForLoader: Record<Loader, ParserOptions["plugins"]> = {
-  js: [],
-  jsx: ["jsx"],
-  ts: ["typescript"],
-  tsx: ["typescript", "jsx"],
+  js: [...additionalLanguageFeatures],
+  jsx: ["jsx", ...additionalLanguageFeatures],
+  ts: ["typescript", ...additionalLanguageFeatures],
+  tsx: ["typescript", "jsx", ...additionalLanguageFeatures],
 };
 
 const cache = new LRUCache<string, string>({ max: 1000 });


Test patch:
diff --git a/packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts b/packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts
index 7c67d4a03..f9eac955a 100644
--- a/packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts
+++ b/packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts
@@ -222,4 +222,255 @@ describe("addSuffixToCssSideEffectImports", () => {
       `);
     });
   });
+
+  describe("parser support for language features", () => {
+    function languageFeaturesFixture(options: { ts: boolean; jsx: boolean }) {
+      let tsLanguageFeatures = dedent`
+        // TS
+        const exampleSatisfies = 'satisfies' satisfies string;
+        enum ExampleEnum {
+          Foo,
+          Bar,
+          Baz,
+        }
+      `;
+
+      let jsxLanguageFeatures = dedent`
+        // JSX
+        const ExampleComponent = () => <div>JSX element</div>;
+      `;
+
+      let jsLanguageFeatures = dedent`
+        // JS
+        const topLevelAwait = await Promise.resolve('top level await');        
+        function classDecorator(target) {
+          return target;
+        }        
+        function methodDecorator(target) {
+          return target;
+        }
+        @classDecorator
+        class ExampleClass {
+          #privateField;
+          #privateFieldWithInitializer = 'private field with initializer';
+          #privateMethod() {
+            return 'private method';
+          }
+          @methodDecorator
+          decoratedMethod() {
+            return 'decorated method';
+          }
+        }        
+        const numericSeparator = 1_000_000;
+        const nullishCoalescing = null ?? 'nullish coalescing';
+        const optionalChaining = (['optional', 'chaining'])?.join?.(' ');
+        let optionalCatchBinding;
+        try {
+          optionalCatchBinding = error();
+        } catch {
+          optionalCatchBinding = 'optional catch binding';
+        }
+        export async function* asyncGenerator() {
+          yield await Promise.resolve('async generator');
+        }
+      `;
+
+      return [
+        'require("./foo.css")',
+        ...(options.ts ? [tsLanguageFeatures] : []),
+        ...(options.jsx ? [jsxLanguageFeatures] : []),
+        jsLanguageFeatures,
+      ].join("\n\n");
+    }
+
+    test("JS language features", () => {
+      let code = languageFeaturesFixture({ ts: false, jsx: false });
+
+      expect(addSuffixToCssSideEffectImports("js", code))
+        .toMatchInlineSnapshot(`
+        "require(\\"./foo.css?__remix_sideEffect__\\");
+
+        // JS
+        const topLevelAwait = await Promise.resolve('top level await');
+        function classDecorator(target) {
+          return target;
+        }
+        function methodDecorator(target) {
+          return target;
+        }
+        @classDecorator class
+        ExampleClass {
+          #privateField;
+          #privateFieldWithInitializer = 'private field with initializer';
+          #privateMethod() {
+            return 'private method';
+          }
+          @methodDecorator
+          decoratedMethod() {
+            return 'decorated method';
+          }
+        }
+        const numericSeparator = 1_000_000;
+        const nullishCoalescing = null ?? 'nullish coalescing';
+        const optionalChaining = ['optional', 'chaining']?.join?.(' ');
+        let optionalCatchBinding;
+        try {
+          optionalCatchBinding = error();
+        } catch {
+          optionalCatchBinding = 'optional catch binding';
+        }
+        export async function* asyncGenerator() {
+          yield await Promise.resolve('async generator');
+        }"
+      `);
+    });
+
+    test("JSX language features", () => {
+      let code = languageFeaturesFixture({ ts: false, jsx: true });
+
+      expect(addSuffixToCssSideEffectImports("jsx", code))
+        .toMatchInlineSnapshot(`
+        "require(\\"./foo.css?__remix_sideEffect__\\");
+
+        // JSX
+        const ExampleComponent = () => <div>JSX element</div>;
+
+        // JS
+        const topLevelAwait = await Promise.resolve('top level await');
+        function classDecorator(target) {
+          return target;
+        }
+        function methodDecorator(target) {
+          return target;
+        }
+        @classDecorator class
+        ExampleClass {
+          #privateField;
+          #privateFieldWithInitializer = 'private field with initializer';
+          #privateMethod() {
+            return 'private method';
+          }
+          @methodDecorator
+          decoratedMethod() {
+            return 'decorated method';
+          }
+        }
+        const numericSeparator = 1_000_000;
+        const nullishCoalescing = null ?? 'nullish coalescing';
+        const optionalChaining = ['optional', 'chaining']?.join?.(' ');
+        let optionalCatchBinding;
+        try {
+          optionalCatchBinding = error();
+        } catch {
+          optionalCatchBinding = 'optional catch binding';
+        }
+        export async function* asyncGenerator() {
+          yield await Promise.resolve('async generator');
+        }"
+      `);
+    });
+
+    test("TS language features", () => {
+      let code = languageFeaturesFixture({ ts: true, jsx: false });
+
+      expect(addSuffixToCssSideEffectImports("tsx", code))
+        .toMatchInlineSnapshot(`
+        "require(\\"./foo.css?__remix_sideEffect__\\");
+
+        // TS
+        const exampleSatisfies = ('satisfies' satisfies string);
+        enum ExampleEnum {
+          Foo,
+          Bar,
+          Baz,
+        }
+
+        // JS
+        const topLevelAwait = await Promise.resolve('top level await');
+        function classDecorator(target) {
+          return target;
+        }
+        function methodDecorator(target) {
+          return target;
+        }
+        @classDecorator class
+        ExampleClass {
+          #privateField;
+          #privateFieldWithInitializer = 'private field with initializer';
+          #privateMethod() {
+            return 'private method';
+          }
+          @methodDecorator
+          decoratedMethod() {
+            return 'decorated method';
+          }
+        }
+        const numericSeparator = 1_000_000;
+        const nullishCoalescing = null ?? 'nullish coalescing';
+        const optionalChaining = ['optional', 'chaining']?.join?.(' ');
+        let optionalCatchBinding;
+        try {
+          optionalCatchBinding = error();
+        } catch {
+          optionalCatchBinding = 'optional catch binding';
+        }
+        export async function* asyncGenerator() {
+          yield await Promise.resolve('async generator');
+        }"
+      `);
+    });
+
+    test("TSX language features", () => {
+      let code = languageFeaturesFixture({ ts: true, jsx: true });
+
+      expect(addSuffixToCssSideEffectImports("tsx", code))
+        .toMatchInlineSnapshot(`
+        "require(\\"./foo.css?__remix_sideEffect__\\");
+
+        // TS
+        const exampleSatisfies = ('satisfies' satisfies string);
+        enum ExampleEnum {
+          Foo,
+          Bar,
+          Baz,
+        }
+
+        // JSX
+        const ExampleComponent = () => <div>JSX element</div>;
+
+        // JS
+        const topLevelAwait = await Promise.resolve('top level await');
+        function classDecorator(target) {
+          return target;
+        }
+        function methodDecorator(target) {
+          return target;
+        }
+        @classDecorator class
+        ExampleClass {
+          #privateField;
+          #privateFieldWithInitializer = 'private field with initializer';
+          #privateMethod() {
+            return 'private method';
+          }
+          @methodDecorator
+          decoratedMethod() {
+            return 'decorated method';
+          }
+        }
+        const numericSeparator = 1_000_000;
+        const nullishCoalescing = null ?? 'nullish coalescing';
+        const optionalChaining = ['optional', 'chaining']?.join?.(' ');
+        let optionalCatchBinding;
+        try {
+          optionalCatchBinding = error();
+        } catch {
+          optionalCatchBinding = 'optional catch binding';
+        }
+        export async function* asyncGenerator() {
+          yield await Promise.resolve('async generator');
+        }"
+      `);
+    });
+  });
 });