Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Since 1.77.5 @extend called inside @mixin from loop (1400 icons) causes crash or unacceptable compile times #2504

Open
acketon opened this issue Feb 4, 2025 · 11 comments

Comments

@acketon
Copy link

acketon commented Feb 4, 2025

Background

We have a SASS repo that serves as our CSS framework for our WordPress Theme Framework here at Boston University. To be fair at the start it is an old version with old code that we are working to replace. However in the meantime we've run into an issue that I've isolated to a change in 1.77.5 which made some change to @extend.

In our SASS that is compiled we have an @mixin that includes an @extend. The @mixin is an icon mixin and the @extend is pretty simple, it is just adding the shared styles for all of the icons (font family, font-style, line-height, text-transform, etc).

The @mixin however is called a massive amount of times from an @each that loops over a map of 1400+ icons. So this mixin and @extend is called a lot on every build. It's been this way for many years and compiled quickly on node-sass previously. We are moving now to webpack and dart-sass as part of these updates.

1.77.1

Earlier last year I was able to compile this code as-is with all 1,400+ icons quickly with dart-sass 1.77.1 at the time. In recent weeks a colleague started working on the repo and installed the deps which resulted in her getting a newer version of dart-sass (1.83). For her the compile was never finishing or at least taking so long (5-10+ minutes) that it appears to crash/hang and we quite the build. I was able to recreate this on my machine by installing newer versions of dart-sass.

1.77.5

To try to isolate the problem I've tested each version of dart sass and it seems to be that the changes in 1.77.5 are causing the crash/hang during compile. 1.77.4 compiles a build in a painful 32 seconds, but 1.77.5 does not finish after even 15 minutes. If I remove the @extend from the @mixin the 1,400 icons compile with 1.77.5 in 12 seconds.

Some code samples:

@each:

@each $name, $content in $icons-responsive {
	%icon-#{$name} {
		@include icon( $name, $icons-positioning, true );
	}

	@if $print-icon-classes {
		.icon-#{$name} {
			@extend %icon-#{$name};
		}
	}
}

@mixin: (@extend %icon-content-#{$position}; line is the problem)

@mixin icon( $name, $position: "before", $use-extend: false ) {
	@include icon-base;
	$content: fa-content( map-get( $icons-responsive, $name ) );

	@if $use-extend {
		@extend %icon-content-#{$position};
	}

	&::#{$position} {
		@if $use-extend == false {
			@include icon-content;

			@if $position == "before" {
				margin-right: 0.5em;
			} @else {
				margin-left: 0.5em;
			}
		}

		@if $content {
			content: $content;
		} @else {
			@error "`#{$name}` is not a supported icon. See www.bu.edu/cdn/fonts/icons/bu-default-icons/specimen-icons.html for a list of supported icons. If this is a custom icon you meant to add for your own icon font, be sure you've added it to the `$icons-theme` map.";
		}
	}
}
%icon-content-before {
	&::before {
		@include icon-content;
		margin-right: 0.5em;
	}
}
@mixin icon-content {
	color: $color-icons;
	display: inline-block;
	font-family: $font-family-icons;
	-moz-osx-font-smoothing: grayscale;
	-webkit-font-smoothing: antialiased;
	font-style: normal;
	font-variant: normal;
	font-weight: $font-weight-icon;
	line-height: 1;
	padding-bottom: 0.2em; // Accomodate for inline icons
	speak: none;
	text-decoration: none;
	text-rendering: optimizeLegibility;
	text-transform: none;
	vertical-align: middle;
	white-space: nowrap;
}

If I reduce the map of icons from 1,400+ to an essential list of only 115 icons in the map. That results in dart-sass 1.77.5 successfully compiling in 14 seconds with the @extend enabled. Somewhere between 115 icons and 1,400+ icons makes 1.77.5 crash/hang when the @extend is enabled.

We are going to change things so all 1,400 icons are no longer processed on every build/watch as we don't really need that many but it still does seem to be that something in 1.77.5 changed that breaks/crashes/hangs the build that had been working for many years on both node-sass and last year on earlier versions of dart-sass.

@tomastan
Copy link

tomastan commented Feb 5, 2025

Faced the same issue with significant performance downgrade so locked up to version 1.77.4 until the solution is found

@nex3
Copy link
Contributor

nex3 commented Feb 5, 2025

Can either of you provide an example stylesheet that demonstrates the performance regression?

@tomastan
Copy link

tomastan commented Feb 6, 2025

In our case it is somehow connected with @extend and boostrap-sass 3.4.3 (since we still stuck with bootstrap 3).

On sass 1.77.4:

Image

On the latest version of sass:

Image

So the difference is 10x slower for this specific code. On actual code (not stripped down here for your convenience) the difference is much more higher, like 20x+.

The example to check is below. It relies on [email protected] as otherwise the hard to replicate. Interestingly on line number 4 importing button-groups looks to have a significant impact on the performance??!

@import "bootstrap-sass/assets/stylesheets/bootstrap/variables";
@import "bootstrap-sass/assets/stylesheets/bootstrap/mixins";
@import "bootstrap-sass/assets/stylesheets/bootstrap/buttons";
@import "bootstrap-sass/assets/stylesheets/bootstrap/button-groups"; // !!! When commenting this line, everything speeds up

.transparent-border{
	border-color: transparent !important;
}

@mixin btn-light{
	&, &:hover, &:focus, &:active, &:hover:active, &:focus:active, &:focus-visible{
		color: inherit;
		outline: initial;

		// Dark mode
		[data-theme="dark"] &{
			color: white;
		}
	}
}

@mixin btn-dark{
	&, &:hover, &:focus, &:active, &:hover:active, &:focus:active, &:focus-visible{
		color: white;
		outline: initial;

		// Dark mode
		[data-theme="dark"] &{
			color: white;
		}
	}
}

.btn-success{
	@extend .btn;
	@include btn-dark;

	border-color: transparent;

	&:hover, &:focus, &:active, &:hover:active, &:focus:active{
		border-color: transparent;
	}
}

.btn-primary{
	@extend .btn;
	@include btn-dark;
	@extend .transparent-border;
}

.btn-secondary{
	@extend .btn;
	@include btn-light;

	// Dark mode
	[data-theme="dark"] &{
		background-color: transparent;
	}
}

.btn-danger{
	@extend .btn;
	@include btn-dark;
}

.btn-default{
	@extend .btn;
	@include btn-light;

	color: #6C6C6C !important;
	// @include transparent-border;

	// Dark mode
	[data-theme="dark"] &{
		background-color: transparent;
	}
}

.btn-link-back{
	@extend .btn;

	display: inline-block;
	padding-left: 0;
	font-weight: 500;
	margin-bottom: 10px;

	&:hover, &:focus, &:active, &:hover:active, &:focus:active{
		text-decoration: none;
		box-shadow: none;
	}

	// Hide this link on mobile phones
	@media(max-width: $screen-sm-max){
		display: none;
	}
}

.btn-get-prize{
	@extend .btn;
	@include btn-dark;

	width: 100%;
	margin-top: 10px;
}

.btn-toggle-favourites{
	@extend .btn;
	@extend .btn-primary;

	width: 100%;
	margin-top: 10px;
}

.btn-points{
	@extend .btn;
	@include btn-dark;
	
}

.btn-collect, .btn-collect-nopoints{
	@extend .btn;
	@include btn-dark;

	background-color: #939;
}

.btn-accept{
	@extend .btn;
	@include btn-dark;
	
	margin-left: 5px;
	background-color: #c06;
}

.btn-challenge-close{
	@extend .btn;
	@include btn-dark;

	width: 100%;
	margin-top: 10px;
}

.btn-challenge-accept{
	@extend .btn;
	@include btn-dark;

	margin-left: 5px;
	background-color: #c06;
	float: right;

	@media(max-width: $screen-sm-max){
		width: 100%;
		float: none;
		margin-left: 0;
	}
}

.btn-challenge-answer{
	@extend .btn;
	@include btn-dark;

	background-color: #063;
	text-align: center;
	margin-top: 10px;
	white-space: normal;

	/* Don't let the button go too wide */
	white-space: normal;
}

.btn-points-transfer{
	@extend .btn-primary;
}

.btn-login-customer{
	@extend .btn;
	@include btn-dark;
}

.btn-login-phone{
	@extend .btn;
	@include btn-dark;

}

.btn-login-facebook{
	@extend .btn;
	@include btn-dark;

	background-color: #1877F2 !important;
}

.btn-login-linkedin{
	@extend .btn;
	@include btn-dark;

	background-color: #0077B5 !important;
}

.btn-login-google{
	@extend .btn;
	@include btn-dark;
	
	background-color: #4285F4 !important;
}

.btn-login-apple{
	@extend .btn;
	@include btn-dark;
	
	background-color: #777 !important;
}

.btn-next{
	@extend .btn;
	@include btn-dark;
	
	background-color: #38c0ea !important;
}

.btn-back{
	@extend .btn-default;
}

.btn-ask-name-popup{
	@extend .btn-primary;
}

.btn-deactivate-account{
	@extend .btn-danger;
	@include btn-dark;
}

.btn-reward-get-by-sms,
.btn-reward-get-by-email,
.btn-reward-print{
	@extend .btn;
	@include btn-dark;
	@extend .btn-block;
	
}

.btn-add-to-google-wallet, .btn-add-to-apple-wallet{
	@extend .btn;
	@include btn-dark;
	@extend .btn-block;
	
	background-color: #1f1f1f;
	border: 1px solid #747775;
}

.btn-reward-mark-as-used{
	@extend .btn-primary;
	@include btn-dark;
	@extend .btn-block;
}

.btn-fb-share{
	@extend .btn;
	@include btn-dark;

	background-color: #3b5998;

	@media(max-width: $screen-sm-max){
		background-color: transparent;
		font-size: 18px;
		padding: 0;
		margin-top: 5px;
	}
}

.btn-share-button{
	@extend .btn;
	@include btn-dark;

	margin-top: 5px;

	@media(max-width: $screen-sm-max){
		width: auto;
		background-color: transparent;
		color: white;
	}
}

.btn-news-mark-all-read, .btn-news-mark-as-unread{
	@extend .btn-default;
	@extend .btn-sm;
	@include btn-light;
}

@ntkme
Copy link
Contributor

ntkme commented Feb 7, 2025

I did some test and have an interesting discovery. - The performance scales non-linearly with the number of @extend being used. See the test below, where I tried to compile the same @extend being used from only once to 50 times in a stylesheet, and measured the time for each compilation. You can see from the latency is increasing exponentially when the same @extend is repeatedly used.

Image

Test code:

// test.scss
@import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/variables";
@import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/mixins";
@import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/buttons";
@import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/button-groups";

$count: count();

@for $i from 1 through $count {
  ul:nth-child(#{$i}) {
    @extend .btn;
  }
}
# test.rb
require 'benchmark'
require 'sass-embedded'

puts Sass.info

(1..50).each do |i|
  r = Benchmark.measure do
    Sass.compile('test.scss', logger: Sass::Logger.silent, functions: { 'count()' => ->(_) { Sass::Value::Number.new(i) } })
  end
  puts "#{i}:\t#{r}"
end

Result:

sass-embedded	1.83.4	(Embedded Host)	[Ruby]
dart-sass	1.83.4	(Sass Compiler)	[Dart]
1:	  0.000271   0.000257   0.000528 (  0.018406)
2:	  0.000196   0.000141   0.000337 (  0.011211)
3:	  0.000239   0.000177   0.000416 (  0.013472)
4:	  0.000204   0.000142   0.000346 (  0.014080)
5:	  0.000209   0.000151   0.000360 (  0.017197)
6:	  0.001931   0.000688   0.002619 (  0.024398)
7:	  0.000228   0.000099   0.000327 (  0.028517)
8:	  0.000261   0.000148   0.000409 (  0.038380)
9:	  0.000258   0.000166   0.000424 (  0.050256)
10:	  0.000247   0.000142   0.000389 (  0.066057)
11:	  0.000242   0.000133   0.000375 (  0.088831)
12:	  0.000253   0.000190   0.000443 (  0.115928)
13:	  0.000254   0.000167   0.000421 (  0.151541)
14:	  0.000259   0.000155   0.000414 (  0.193962)
15:	  0.000251   0.000189   0.000440 (  0.253000)
16:	  0.000288   0.000226   0.000514 (  0.322193)
17:	  0.000270   0.000145   0.000415 (  0.407341)
18:	  0.000258   0.000173   0.000431 (  0.509078)
19:	  0.000269   0.000209   0.000478 (  0.631955)
20:	  0.000255   0.000206   0.000461 (  0.787263)
21:	  0.000256   0.000249   0.000505 (  0.950930)
22:	  0.000274   0.000196   0.000470 (  1.154545)
23:	  0.000264   0.000186   0.000450 (  1.389785)
24:	  0.000260   0.000195   0.000455 (  1.667447)
25:	  0.000266   0.000223   0.000489 (  1.983225)
26:	  0.000266   0.000181   0.000447 (  2.350908)
27:	  0.000277   0.000188   0.000465 (  2.772923)
28:	  0.000386   0.000245   0.000631 (  3.284851)
29:	  0.000280   0.000222   0.000502 (  3.832544)
30:	  0.000297   0.000220   0.000517 (  4.446423)
31:	  0.000290   0.000170   0.000460 (  5.166122)
32:	  0.000305   0.000248   0.000553 (  5.971144)
33:	  0.000340   0.000377   0.000717 (  7.057029)
34:	  0.000297   0.000314   0.000611 (  7.844323)
35:	  0.000330   0.000249   0.000579 (  9.015079)
36:	  0.001637   0.000738   0.002375 ( 10.195949)
37:	  0.000299   0.000237   0.000536 ( 11.550523)
38:	  0.000321   0.000214   0.000535 ( 13.037178)
39:	  0.000365   0.000219   0.000584 ( 14.768409)
40:	  0.000342   0.000259   0.000601 ( 16.814541)
41:	  0.000374   0.000253   0.000627 ( 19.070963)
42:	  0.000378   0.000226   0.000604 ( 21.003073)
43:	  0.000338   0.000267   0.000605 ( 23.029521)
44:	  0.000323   0.000269   0.000592 ( 25.701685)
45:	  0.000352   0.000272   0.000624 ( 28.588297)
46:	  0.000324   0.000247   0.000571 ( 31.430678)
47:	  0.000343   0.000258   0.000601 ( 34.943156)
48:	  0.000346   0.000279   0.000625 ( 38.251602)
49:	  0.000347   0.000301   0.000648 ( 42.372161)
50:	  0.000334   0.000302   0.000636 ( 46.483756)

@ntkme
Copy link
Contributor

ntkme commented Feb 7, 2025

Also, @tomastan is right about commenting out button-groups making it way faster, but I think it's unrelated to the bug. It's just a normal outcome due to the same @extend .btn is getting smaller to begin with.

// @import "node_modules/bootstrap-sass/assets/stylesheets/bootstrap/button-groups";

This is how it looks with button-groups commented out. The latency starts with a lower number at the beginning, but still increasing exponentially while number of iterations increase linearly.

Image

@ntkme
Copy link
Contributor

ntkme commented Feb 7, 2025

@nex3 I'm able to reproduce the issue with no dependencies at all.

The summary is that the cost of the same @extend is increasing exponentially when used repeatedly.

Image

The production is written in ruby, but you can easily rewrite it in JS or Dart if needed.

# test.rb

require 'benchmark'
require 'sass-embedded'

puts Sass.info

(1..100).each do |i|
  i = i*10 # use an increment of 10 to speed it up
  r = Benchmark.measure do
    Sass.compile('test.scss', logger: Sass::Logger.silent, functions: { 'count()' => ->(_) { Sass::Value::Number.new(i) } })
  end
  puts "#{i}:\t#{r}"
end
// test.scss
.btn {
  color: #000;
}

$count: count();

@for $i from 1 through $count {
  ul:nth-child(#{$i}) {
    @extend .btn;
  }
}

@ntkme
Copy link
Contributor

ntkme commented Feb 7, 2025

Or even simpler, this take ~18 seconds on my laptop with Dart AOT snapshot:

// test.scss
.btn {
  color: #000;
}

@for $i from 1 through 1000 {
  ul:nth-child(#{$i}) {
    @extend .btn;
  }
}

On dart-sass 1.77.4, the same code only took 0.005s.

@acketon
Copy link
Author

acketon commented Feb 7, 2025

That aligns with what I've seen. Thank you for investigating.

I've seen it taking 20+ minutes with our codebase. Actually at some points I'm not even sure if it is still working or crashed.

@tomastan
Copy link

tomastan commented Feb 7, 2025

@nex3 I'm able to reproduce the issue with no dependencies at all.
The summary is that the cost of the same @extend is increasing exponentially when used repeatedly.

@ntkme Yes, I suspected something wrong with @extend but you perfectly narrowed it down to the pure essence!

@ntkme
Copy link
Contributor

ntkme commented Feb 7, 2025

FlameGraph and profiling data from Dart devtools: dart_devtools_2025-02-07_06_22_31.421.json

Image

@ntkme
Copy link
Contributor

ntkme commented Feb 7, 2025

The regression is for sure caused by #2255. In fact, the comment in lib/src/extend/extension_store.dart gives us cue on what is happening: ecff05d#diff-e5199e6bd941df0b45fe3c453924cfb3f806c0bafaee44b41ade4f2861f4957eL904-L910

diff --git a/lib/src/extend/extension_store.dart b/lib/src/extend/extension_store.dart
index 3637b5aac..2bbc2a9cb 100644
--- a/lib/src/extend/extension_store.dart
+++ b/lib/src/extend/extension_store.dart
@@ -901,13 +901,6 @@ class ExtensionStore {
   // document, and thus should never be trimmed.
   List<ComplexSelector> _trim(List<ComplexSelector> selectors,
       bool isOriginal(ComplexSelector complex)) {
-    // Avoid truly horrific quadratic behavior.
-    //
-    // TODO(nweiz): I think there may be a way to get perfect trimming without
-    // going quadratic by building some sort of trie-like data structure that
-    // can be used to look up superselectors.
-    if (selectors.length > 100) return selectors;
-
     // This is n² on the sequences, but only comparing between separate
     // sequences should limit the quadratic behavior. We iterate from last to
     // first and reverse the result so that, if two selectors are identical, we

For my minimal reproduction, just adding back if (selectors.length > 100) return selectors; is enough to bring the performance back to a reasonable number.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

4 participants