From b3590877cf75f2ec51f2e872a3da07cdad2a1a76 Mon Sep 17 00:00:00 2001 From: Mark Dalgleish Date: Mon, 30 Jan 2023 09:59:14 +1100 Subject: [PATCH] fix: support decorators with CSS side-effects --- .changeset/honest-crabs-sort.md | 5 + .../__tests__/cssSideEffectsPlugin-test.ts | 251 ++++++++++++++++++ .../plugins/cssSideEffectImportsPlugin.ts | 10 +- 3 files changed, 262 insertions(+), 4 deletions(-) create mode 100644 .changeset/honest-crabs-sort.md diff --git a/.changeset/honest-crabs-sort.md b/.changeset/honest-crabs-sort.md new file mode 100644 index 00000000000..448b7a13eef --- /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/__tests__/cssSideEffectsPlugin-test.ts b/packages/remix-dev/__tests__/cssSideEffectsPlugin-test.ts index 7c67d4a031f..f9eac955a95 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 = () =>
JSX element
; + `; + + 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 = () =>
JSX element
; + + // 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 = () =>
JSX element
; + + // 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'); + }" + `); + }); + }); }); diff --git a/packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts b/packages/remix-dev/compiler/plugins/cssSideEffectImportsPlugin.ts index 6463c2d7b63..57ff4653d52 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 = { - js: [], - jsx: ["jsx"], - ts: ["typescript"], - tsx: ["typescript", "jsx"], + js: [...additionalLanguageFeatures], + jsx: ["jsx", ...additionalLanguageFeatures], + ts: ["typescript", ...additionalLanguageFeatures], + tsx: ["typescript", "jsx", ...additionalLanguageFeatures], }; const cache = new LRUCache({ max: 1000 });