angular / angular-cli

CLI tool for Angular
https://cli.angular.io
MIT License
26.73k stars 11.98k forks source link

feat: ✨ Improve Angular / TailwindCSS developer story #26378

Closed bjornharvold closed 10 months ago

bjornharvold commented 10 months ago

Command

serve

Description

When using the new esbuild feature from Angular 17.0.3 and TailwindCss, it takes us 500+ seconds to start up a large application. When we make a simple code change, it takes 280+ seconds for esbuild to rebuild.

The esbuild build story is GREAT. Working with esbuild and TailwindCSS on a daily basis is a completely different story.

Wondering what's possible here. The current situation is not tenable.

FYI Before we used Nx's incremental webpack build and that worked in an "acceptable" way. If the change was in the "top most" part of the application, a rebuild was snappy. If the change was made in the "lower part" of the app, the dev server would have to work its way up to the top by compiling everything in between.

You definitely see a delay when having to pre-process the CSS in a larger application in comparison to an Angular app that runs with SCSS / Bootstrap for example.

Thoughts from the Team Angular highly requested 🍺

Describe the solution you'd like

@vbraun said that it works fine for him with a static SCSS TailwindCSS solution here: https://github.com/angular/angular-cli/issues/25130#issuecomment-1814106769 but that the CSS doesn't get pruned.

Wondering if we can have TailwindCSS classes be configured to be pruned on a production build but use the entire TailwindCSS library during development (serve).

If we can define a development configuration that sucks in TailwindCSS in its entirety and a build configuration that kicks off post-processing of TailwindCSS in standalone component libraries and does the pruning, we might have ourselves a working solution.

Describe alternatives you've considered

No response

alan-agius4 commented 10 months ago

Hi @bjornharvold,

Thanks for the issue. It's worth mentioning that TailwindCSS requires postcss which does force a slow path for CSS processing. As the application grows this will become more and more noticeable.

That said, to undertstand a bit better the issue and that you are using the optimal Tailwinds settings, can you please share your tailwind configuration?

bjornharvold commented 10 months ago

Hi @alan-agius4

Our shared config looks like this:

function withOpacityValue(variable) {
  return ({ opacityValue }) => {
    if (opacityValue === undefined) {
      return `rgb(var(${variable}))`;
    }
    return `rgb(var(${variable}) / ${opacityValue})`;
  };
}

/** @type {import('tailwindcss').Config} */
module.exports = {
  prefix: 'wink-',
  important: true,
  theme: {
    colors: {
      transparent: 'transparent',
      current: 'currentColor',
      primary: withOpacityValue('--wink-color-primary'),
      secondary: withOpacityValue('--wink-color-secondary'),
      success: withOpacityValue('--wink-color-success'),
      danger: withOpacityValue('--wink-color-danger'),
      warning: withOpacityValue('--wink-color-warning'),
      info: withOpacityValue('--wink-color-info'),
      light: withOpacityValue('--wink-color-light'),
      dark: withOpacityValue('--wink-color-dark'),
      body: withOpacityValue('--wink-color-body'),
      muted: withOpacityValue('--wink-color-muted'),
      white: withOpacityValue('--wink-color-white'),
    },
    extend: {
    },
  },
  corePlugins: {
    aspectRatio: true,
  },
  plugins: [
    require('@tailwindcss/forms'),
    // require('@tailwindcss/aspect-ratio'),
  ],
};

Our styles.css has 3 Tailwind includes:

@tailwind base;
@tailwind components;
@tailwind utilities;

All our standalone components have only 2:

@tailwind components;
@tailwind utilities;

and their Tailwind config refers back to the shared presets:

const { createGlobPatternsForDependencies } = require('@nx/angular/tailwind');
const { join } = require('path');
const sharedTailwindConfig = require('../../../libs/tailwind-preset/tailwind-config');

module.exports = {
  presets: [sharedTailwindConfig],
  content: [
    join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'),
    ...createGlobPatternsForDependencies(__dirname),
  ],
};
alan-agius4 commented 10 months ago

I dug a bit into this and there are 2 potential issues.

  1. The paths in the content will return pretty much all the files of the application, thus causing any of the already stylesheet to be invalidated upon any change. This is because any of matching files will be marked as a direct dependency of the CSS stylesheet. This fundamentally is not an issue that we can fix from our end as it's how tailwinds is configured to work.

  2. For every component style, we create a new instance of the PostCSS which in turns create a new instance of tailwind, which causes the a large number of FS calls to return the find the files matching the globs. This is something that we should be able to handle.

bjornharvold commented 10 months ago

Regarding 1. Nx's incremental builds only requires recompiling components that come between the changed component and the app (top-most layer). Wondering if Angular can do something similar with its cache. I am guessing Angular does something similar, becase, as I mentioned, a re-build only takes 50% of the time compared to the starting the app. That sounds good but when the startup is 10 mins and every rebuild is 5 mins... not a lot is able to get done in one day 😬

Also, is it worth reaching out to the guys at Tailwind for 1 at all?

Cheers

clydin commented 10 months ago

What you are describing is actually how the CLI's rebuilds work in watch mode. Unfortunately, the issue here is that the Tailwind configuration is causing Tailwind to notify the build system that all files within the contents option will affect all stylesheets with Tailwind usage. This means that any change to a file referenced in contents will cause all those stylesheets to be rebuilt since they could change as a result. If any of those stylesheets are used by a component then the component itself may need to be changed so it will get rebuilt. Effectively, there are no longer any layers. Rather, the majority of the application's files are now dependent on each other and each rebuild is a nearly full rebuild.

bjornharvold commented 10 months ago

Wondering if that can somehow be "bypassed" when in serve mode. The esbuild / TailwindCSS story works great. For regular development, I would be fine with PostCSS working once at startup but not for a re-build. I say that because designing the UI with TailwindCSS can easily be done with Storybook. There are mostly Typescript & logic changes in HTML occurring in dev mode during a rebuild; not the changing of CSS. Just an idea πŸ€”

alan-agius4 commented 10 months ago

Tailwind requires PostCSS to run, hence it always needed to run for every change.

In this case, your tailwind configuration is set so that every file in your application is a dependency of every stylesheet. As such in this case the CLI is doing the right thing, that once a any file is changed change it is invalidating all the stylesheets.

In the case you want a different behaviour, you can provide different content based on an environment variable or parameter, but this is not a problem that the Angular CLI should be solving.

An alternative way, would also be to use multiple configurations where with this approach you can limit the dependency scope and fine grain the dependency structure for each stylesheet.

alan-agius4 commented 10 months ago

17.0.3 which was released yesterday contains a performance improvement to only create post css once.

PR: https://github.com/angular/angular-cli/pull/26391

Can you check if this improves the performance for your case? Other than that, I don’t think there is much else we can do to improve the performance for tailwinds for your current configuration.

bjornharvold commented 10 months ago

Hi @alan-agius4

The PR improved the rebuild time. From 290s to 169s for one of our apps. πŸ”₯

Regarding multiple configs...

We have 500+ components. Every component uses the same Tailwind config in its Angular module library so we have 500 identical Tailwind config files like so:

const { createGlobPatternsForDependencies } = require('@nx/angular/tailwind');
const { join } = require('path');
const sharedTailwindConfig = require('../../../libs/tailwind-preset/tailwind-config');

module.exports = {
  presets: [sharedTailwindConfig],
  content: [
    join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'),
    ...createGlobPatternsForDependencies(__dirname),
  ],
};

Not sure how using @config in any of the 500+ components would affect change detection. Currently, a change in one of the 500 components triggers a rebuild for everything. This is where Nx's incremental build saved us to some extent as it completely ignored rebuilding anything below the change tree.

Wondering if there is anything we can do in these 500+ individual but identical Tailwind configs that would stop untouched components from re-building.

alan-agius4 commented 10 months ago

Glad to hear that the performance fix reduced your build times by around 40%.

Having a single tailwinds config is not really scalable when using tailwinds in components. This is because dependencies are not statically inferred instead these needs to be provided through the content option.

Since you have 500 components, you get 500 different styles compilation each of these stylesheets dependent on a very broad content settings which configures the entire application to be a dependency of each stylesheet, this is a what is causing the slow incremental compilation, as at the very minimum even for a trivial change like changing a comment, there are 500 stylesheets that require to be compiled because there are invalidated. Which is all expected and intended from a tooling POV, as that is that the tool has been configured to do.

A more performant way configure tailwind is that each feature, has it's own list of dependencies. Consider the below structure and files.

β”œβ”€β”€ src
β”‚   β”œβ”€β”€ app
β”‚   β”‚   β”œβ”€β”€ app.component.scss         // This sets `@config './app.component.config.js'
β”‚   β”‚   β”œβ”€β”€ app.component.ts
β”‚   β”‚   β”œβ”€β”€ app.component.config.js
β”‚   β”œβ”€β”€ home
β”‚   β”‚   β”œβ”€β”€ home.component.scss         // This sets `@config './home.component.config.js'
β”‚   β”‚   β”œβ”€β”€ home.component.ts
β”‚   β”‚   β”œβ”€β”€ home.component.config.js
β”‚   └── styles.scss
β”œβ”€β”€ tailwind.config.js
└── tailwind.shared.config.js

tailwind..shared.config.js

function withOpacityValue(variable) {
  return ({ opacityValue }) => {
    if (opacityValue === undefined) {
      return `rgb(var(${variable}))`;
    }
    return `rgb(var(${variable}) / ${opacityValue})`;
  };
}

/** @type {import('tailwindcss').Config} */
module.exports = {
  prefix: 'wink-',
  important: true,
  theme: {
    colors: {
      transparent: 'transparent',
      current: 'currentColor',
      primary: withOpacityValue('--wink-color-primary'),
      secondary: withOpacityValue('--wink-color-secondary'),
      success: withOpacityValue('--wink-color-success'),
      danger: withOpacityValue('--wink-color-danger'),
      warning: withOpacityValue('--wink-color-warning'),
      info: withOpacityValue('--wink-color-info'),
      light: withOpacityValue('--wink-color-light'),
      dark: withOpacityValue('--wink-color-dark'),
      body: withOpacityValue('--wink-color-body'),
      muted: withOpacityValue('--wink-color-muted'),
      white: withOpacityValue('--wink-color-white'),
    },
    extend: {
    },
  },
  corePlugins: {
    aspectRatio: true,
  },
  plugins: [
    require('@tailwindcss/forms'),
    // require('@tailwindcss/aspect-ratio'),
  ],
};

tailwind.config.js

const { join } = require('path');
const sharedTailwindConfig = require('./tailwind..shared.config.js');

module.exports = {
  presets: [sharedTailwindConfig],
  content: [
    join(__dirname, 'src/**/!(*.stories|*.spec).{ts,html}'),
  ],
};

<app|home>.component.config.js

const { join } = require('path');
const sharedTailwindConfig = require('./tailwind..shared.config.js');

module.exports = {
  presets: [sharedTailwindConfig],
  content: [
    join(__dirname, '**/!(*.stories|*.spec).{ts,html}'),
  ],
};

In the above example, the app.component.scss will only get invalidated when something under the app directory is modified or other direct dependencies which can be inferred during statically during the build. This is because in app.component.config.js the content option has a different values for each component stylesheet.

bjornharvold commented 10 months ago

Hi @alan-agius4

Trying that out. What happens with tailwind.config.js in your setup? Looks like it's not being used. Or, correct me if I am wrong, it just needs to be in the root of the SPA app to be picked up by the pre-processor?

Should I remove tailwindConfig completely from the project.json component libraries (assuming this is an Nx-specific setting) and instead move each individual file to the same directory as my .css file?

Excited to see how this performs 🍺

bjornharvold commented 10 months ago

OMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMG πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯

@alan-agius4 You singlehandedly brought our ng serve time down from 596s to 18s with this change. Rebuild time went from 169s to 6s (on certain re-builds < 1s). You made working with TailwindCSS FUN again πŸ¦Έβ€β™‚οΈπŸš€πŸ¦Έβ€β™‚οΈπŸš€πŸ¦Έβ€β™‚οΈπŸš€πŸ¦Έβ€β™‚οΈπŸš€

πŸ™‡β€β™‚οΈπŸ™‡β€β™‚οΈπŸ™‡β€β™‚οΈ THANK YOU!!!!! πŸ™‡β€β™‚οΈπŸ™‡β€β™‚οΈπŸ™‡β€β™‚οΈ

πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯πŸ”₯ OMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMGOMG

alan-agius4 commented 10 months ago

@bjornharvold, super glad to hear that this made such as big improvements for your codebase and DX.

The root level tsconfig is used for the global stylesheets and also I am not 100% sure if it is used by tailwindcss to extend the @config provided from the root directory

angular-automatic-lock-bot[bot] commented 9 months ago

This issue has been automatically locked due to inactivity. Please file a new issue if you are encountering a similar or related problem.

Read more about our automatic conversation locking policy.

This action has been performed automatically by a bot.