Repository: a-h/templ (Go)

Issue:
Bug: auto-generated CSS class name clashing
# Bug
## Initial context
I'm building an app that involves rendering the periodic table. I'm new to templ (this is my first project on it).

## To Reproduce
I've provided the code [in this repo](https://github.com/GustavoSept/buggy_templ/tree/main).
Simply run `docker compose up -d --build` and point a browser to `localhost:4444`.

## Bug Description
### The code
Each chemical element is a component. I'm rendering them using this code (notice how, for each sub-div, we're creating a new class):
```templ
package components

import (
    "webapp/components/ui"
    "webapp/utils/types"
    "webapp/utils/constants"
    "strconv"
)

css elementWrapperStyle(element types.Element){
    grid-column: { strconv.Itoa(element.ColNum) };
    grid-row: { strconv.Itoa(element.RowNum) };
}

templ Home() {
    @BaseBare("home") {
        <div class="periodicTableGrid">
            for _, element := range constants.PeriodicTable {
                <div class={ elementWrapperStyle(element) }>
                    @ui.ElementBlock(element)
                </div>
            }
        </div>
    }
}
```

I have a hard-coded Array of Literals containing the periodic table. It looks like this:
```go
var PeriodicTable = []types.Element{
	{Name: "Hydrogen", Symbol: "H", AtomicNumber: 1, Group: 1, Period: 1, Category: "Nonmetal", Color: "#30c93d", ColNum: 1, RowNum: 1},
	{Name: "Helium", Symbol: "He", AtomicNumber: 2, Group: 18, Period: 1, Category: "Noble Gas", Color: "#06bbe8", ColNum: 18, RowNum: 1},
	{Name: "Lithium", Symbol: "Li", AtomicNumber: 3, Group: 1, Period: 2, Category: "Alkali Metal", Color: "#eda30e", ColNum: 1, RowNum: 2},
	
	// Skipping elements for conciseness
	
	// This one will be important later
	{Name: "Thallium", Symbol: "Tl", AtomicNumber: 81, Group: 13, Period: 6, Category: "Post-transition Metal", Color: "#2181bb", ColNum: 13, RowNum: 6},
}
```

### Actual behavior
The issue is that this code consistently generates a single naming clash when templ generates the class for Hydrogen and Thallium specifically. 

Here's a comparison of outputs:

| Expected Table         | Actual Table           |
|------------------------|------------------------|
| ![Expected](https://imgur.com/nH7I20M.png) | ![Actual](https://imgur.com/bN8GmO6.png) |

And the generated HTML for the buggy table:
<img src="https://imgur.com/aOfgX2M.png" width="500px">

Notice how Hydrogen and Thallium have the same class name `class="elementWrapperStyle_f781"`. It is correctly generated for Hydrogen, at position (1,1).

### Things I tried
- **Removing Hydrogen**: Hydrogen is excluded as expected, and Thallium moves back to its expected position!
- **Altering Hydrogen coordinates**: Altering either Hydrogen's ColNum/RolNum (or both), to any coordinate (including 0,0), will move Thallium to its expected position.
   - Moving Hydrogen to 0,0 actually renders the Expected Table properly. But since it's unexpected behavior, I'm not using this solution.
- **Adding a new element to (1,1)**: Thallium keeps being rendered on (1,1).
- **Removing a random element other than Hydrogen**: Thallium keeps being rendered on (1,1). 
- **Moving Thallium to another coordinate**: Thallium gets moved as expected to the location.
   - If it's a coordinate that already contains an element, they render one on top of the other (which is expected).
- 🚨 ***Swapping a random element's coordinate with Thallium*** 🚨: Thallium gets placed where expected. The other element is rendered on top of Hydrogen.
   - This leads me to believe the problem internally has something to do with the coordinate(13,6) in specific.
   - The generated class name that is clashing both elements in this case is still the same `elementWrapperStyle_f781` as the Hydrogen/Thallium case.

### Hypothesis
I don't know exactly how templ works internally, as I'm new to it. I suppose this is a Hashing issue when generating the name, hence why it's so consistent on outputting the same result, given the same input.
Am i just unlucky that this particular combination of bytes is outputting the same Hash for two different rows, as long as they have the same coordinates?

To test this hypothesis, I've added a third random element to the generated classes:
```templ
css elementWrapperStyle(element types.Element) {
    grid-column: { strconv.Itoa(element.ColNum) };
    grid-row: { strconv.Itoa(element.RowNum) };
    background-color: { element.Color };
}
```

And this also solves the issue! All elements are rendered where they should (and have a terrible look to them. But this was just to test the hypothesis).
![Uggly Table](https://imgur.com/wh7AvK5.png)

## More info
**`templ info` output**
Run `templ info` and include the output:
```bash
(✓) os [ goos=linux goarch=amd64 ]
(✓) go [ location=/home/gustsept/.asdf/shims/go version=go version go1.23.2 linux/amd64 ]
(✓) gopls [ location=/home/gustsept/.asdf/installs/golang/1.23.2/packages/bin/gopls version=golang.org/x/tools/gopls v0.16.2 ]
(✓) templ [ location=/home/gustsept/.asdf/installs/golang/1.23.2/packages/bin/templ version=v0.2.793 ]
```

**Desktop (please complete the following information):**
 - OS: Linux (Bookworm Debian 12.7)
 - templ CLI version (`templ version`): v0.2.793
- Go version (`go version`): go version go1.23.2 linux/amd64
- `gopls` version (`gopls version`): golang.org/x/tools/gopls v0.16.2

### Observation
I've fixed my issue, by implementing the coordinates directly in the div's style as an attribute, like this:
```templ
func elementWrapperStyle(element types.Element) templ.Attributes {
    return templ.Attributes{
        "style": "grid-column: " + strconv.Itoa(element.ColNum) + "; grid-row: " + strconv.Itoa(element.RowNum) + ";",
    }
}

templ Home() {
    @BaseBare("home") {
        <div class="periodicTableGrid">
            for _, element := range constants.PeriodicTable {
                <div { elementWrapperStyle(element)... }>
                    @ui.ElementBlock(element)
                </div>
            }
        </div>
    }
}
```

This solves my problem for now. But what if I actually needed dozens or hundreds of different CSS class names?


Discussion:
I think you hit the limits of the function that's used to calculate the CSS class names.

https://github.com/a-h/templ/blob/4f2ce1648b0ba5b28c6716f43e99ac22cb84108f/runtime.go#L257-L264

As you can see, it only uses the 4 chars of the hex-encoded SHA256 hash of the CSS output once any values have been interpolated. Since it's a cryptographic hash, the output value should be evenly distributed regardless of input value, but since it only uses 4 hex chars there's only 65,536 random suffixes, so if a class name has a lot of usage, it's possible to get a clash.

There's a tradeoff between generated HTML output size, and the chance of collision. I think 65,536 is too low. Using 8 instead chars would be a 32 bit uint max value of 4,294,967,295.

IIRC, I originally picked 4 hex values because I looked up random prefixes that were used in Next.js or some other framework.

So, I think the fix for this... is to increase the number 4 to 8, and to add some tests to cover potential clashes to prevent regression.

Happy to take a PR for this, and happy to take a steer on a larger number than 8 from the author of the PR.
Cool project by the way!
So, essentially I was just unlucky then.
- 65k (4 hex) naming possibilities means that **any two class instance**  (from the same css block) has 0.0015% chance of conflicting like this.
- 1M (5 hex) naming possibilities drops that to 0.000095367% chance.
- 16M (6 hex) naming possibilities drops that to 0.00000596% chance.
- 268M (7 hex) naming possibilities drops that to 0.000000373% chance.
- 4.3B (8 hex) naming possibilities drops that to 0.000000023% chance.

Technically, the problem will always be there though.
I've thought about it, and any solution to make it go to 0% would require either a degraded performance (control conflicts on a per request basis), or would be too much of a hassle to implement.

I'll make a PR to bump it to 8 as soon as i can.



Patch:
diff --git a/.version b/.version
index c9ca53fec..8b99f2a59 100644
--- a/.version
+++ b/.version
@@ -1,1 +1,1 @@
-0.2.793
\ No newline at end of file
+0.2.796
\ No newline at end of file
diff --git a/runtime.go b/runtime.go
index 2009f9f0b..7ed9db99c 100644
--- a/runtime.go
+++ b/runtime.go
@@ -256,11 +256,11 @@ func (css ComponentCSSClass) ClassName() string {
 // CSSID calculates an ID.
 func CSSID(name string, css string) string {
 	sum := sha256.Sum256([]byte(css))
-	hp := hex.EncodeToString(sum[:])[0:4]
+	hs := hex.EncodeToString(sum[:])[0:8] // NOTE: See issue #978. Minimum recommended hs length is 6.
 	// Benchmarking showed this was fastest, and with fewest allocations (1).
 	// Using strings.Builder (2 allocs).
 	// Using fmt.Sprintf (3 allocs).
-	return name + "_" + hp
+	return name + "_" + hs
 }
 
 // NewCSSMiddleware creates HTTP middleware that renders a global stylesheet of ComponentCSSClass


Test patch:
diff --git a/generator/test-css-expression/render_test.go b/generator/test-css-expression/render_test.go
index 0214889e1..295b6d8b4 100644
--- a/generator/test-css-expression/render_test.go
+++ b/generator/test-css-expression/render_test.go
@@ -8,8 +8,8 @@ import (
 )
 
 var expected = templ.ComponentCSSClass{
-	ID:    "className_34fc",
-	Class: templ.SafeCSS(`.className_34fc{background-color:#ffffff;max-height:calc(100vh - 170px);color:#ff0000;}`),
+	ID:    "className_34fc0328",
+	Class: templ.SafeCSS(`.className_34fc0328{background-color:#ffffff;max-height:calc(100vh - 170px);color:#ff0000;}`),
 }
 
 func TestCSSExpression(t *testing.T) {
diff --git a/generator/test-css-middleware/expected.html b/generator/test-css-middleware/expected.html
index 622d4d9ac..df6b148e6 100644
--- a/generator/test-css-middleware/expected.html
+++ b/generator/test-css-middleware/expected.html
@@ -1,3 +1,3 @@
-<div class="red_050e">
+<div class="red_050e5e03">
 	Red text
 </div>
diff --git a/generator/test-css-middleware/render_test.go b/generator/test-css-middleware/render_test.go
index cc7f7e4bf..76a86ccb3 100644
--- a/generator/test-css-middleware/render_test.go
+++ b/generator/test-css-middleware/render_test.go
@@ -16,7 +16,7 @@ import (
 //go:embed expected.html
 var expected string
 
-var expectedCSS = `.red_050e{color:red;}
+var expectedCSS = `.red_050e5e03{color:red;}
 `
 
 func Test(t *testing.T) {
diff --git a/generator/test-css-usage/expected.html b/generator/test-css-usage/expected.html
index af4ebf684..ff3319393 100644
--- a/generator/test-css-usage/expected.html
+++ b/generator/test-css-usage/expected.html
@@ -4,33 +4,33 @@
 	}
 	</style>
 <div class="test">Style tags are supported</div>
-<style type="text/css">.cssComponentGreen_58d2{color:#00ff00;}</style>
-<div class="cssComponentGreen_58d2">CSS components are supported</div>
-<div class="cssComponentGreen_58d2 classA &amp;&amp;&amp;classB classC d e" type="button">Both CSS components and constants are supported</div>
-<div class="cssComponentGreen_58d2 classA &amp;&amp;&amp;classB classC d e" type="button">Both CSS components and constants are supported</div>
+<style type="text/css">.cssComponentGreen_58d2872e{color:#00ff00;}</style>
+<div class="cssComponentGreen_58d2872e">CSS components are supported</div>
+<div class="cssComponentGreen_58d2872e classA &amp;&amp;&amp;classB classC d e" type="button">Both CSS components and constants are supported</div>
+<div class="cssComponentGreen_58d2872e classA &amp;&amp;&amp;classB classC d e" type="button">Both CSS components and constants are supported</div>
 <div class="a c">Maps can be used to determine if a class should be added or not.</div>
-<style type="text/css">.e_739d{font-size:14pt;}</style>
-<div class="a c e_739d">KV can be used to conditionally set classes.</div>
+<style type="text/css">.e_739d4573{font-size:14pt;}</style>
+<div class="a c e_739d4573">KV can be used to conditionally set classes.</div>
 <div class="bg-violet-500 hover:bg-red-600 hover:bg-sky-700 text-[#50d71e] w-[calc(100%-4rem)">Psuedo attributes and complex class names are supported.</div>
 <div class="a&#34; onClick=&#34;alert(&#39;hello&#39;)&#34;">
 	Class names are HTML escaped.
 </div>
 <style type="text/css">
- .loading_a3cc{width:50%;}
+ .loading_a3cc3f08{width:50%;}
 </style>
-<div class="loading_a3cc">
+<div class="loading_a3cc3f08">
  CSS components can be used with arguments.
 </div>
 <style type="text/css">
- .loading_9ccc{width:100%;}
+ .loading_9ccc4ca9{width:100%;}
 </style>
-<div class="loading_9ccc">
+<div class="loading_9ccc4ca9">
  CSS components can be used with arguments.
 </div>
 <style type="text/css">
-  .windVaneRotation_b68b{transform:rotate(45deg);}
+  .windVaneRotation_b68b990e{transform:rotate(45deg);}
 </style>
-<div class="windVaneRotation_b68b">
+<div class="windVaneRotation_b68b990e">
   Rotate
 </div>
 
diff --git a/generator/test-element-attributes/expected.html b/generator/test-element-attributes/expected.html
index 5a6f04a48..03680c7d1 100644
--- a/generator/test-element-attributes/expected.html
+++ b/generator/test-element-attributes/expected.html
@@ -1,16 +1,16 @@
 <style type="text/css">
- .important_2ed1{width:100;}
+ .important_2ed176fc{width:100;}
 </style>
 <div style="width: 100;">
  Important
 </div>
 <style type="text/css">
- .unimportant_900a{width:50;}
+ .unimportant_900aeb18{width:50;}
 </style>
-<div style="width: 100;" class="unimportant_900a">
+<div style="width: 100;" class="unimportant_900aeb18">
  Unimportant
 </div>
-<div style="width: 100;" class="unimportant_900a">
+<div style="width: 100;" class="unimportant_900aeb18">
   Else
  </div>
 <div data-script="on click
diff --git a/runtime_test.go b/runtime_test.go
index 6f864ea47..4f3d34f18 100644
--- a/runtime_test.go
+++ b/runtime_test.go
@@ -15,6 +15,30 @@ import (
 	"github.com/google/go-cmp/cmp"
 )
 
+func TestCSSID(t *testing.T) {
+	t.Run("minimum hash suffix length is 8", func(t *testing.T) {
+		// See issue #978.
+		name := "classA"
+		css := "background-color:white;"
+		actual := len(templ.CSSID(name, css))
+		expected := len(name) + 1 + 8
+		if expected != actual {
+			t.Errorf("expected length %d, got %d", expected, actual)
+		}
+	})
+	t.Run("known hash collisions are avoided", func(t *testing.T) {
+		name := "classA"
+		// Note that the first 4 characters of the hash are the same.
+		css1 := "grid-column:1;grid-row:1;"  // After hash: f781266f
+		css2 := "grid-column:13;grid-row:6;" // After hash: f781f18b
+		id1 := templ.CSSID(name, css1)
+		id2 := templ.CSSID(name, css2)
+		if id1 == id2 {
+			t.Errorf("hash collision: %s == %s", id1, id2)
+		}
+	})
+}
+
 func TestCSSHandler(t *testing.T) {
 	tests := []struct {
 		name             string