Repository: a-h/templ (2025-07-03T10:19:10Z)

Issue:
Add support for spreading non-string attribute values in templ.RenderAttributes
**Describe the bug**

Since merging #1129 it's allowed to do something like this

```
<input type="text" value={ val } />
```

where `val` is an `int`. It will get converted automatically and rendered as

```
<input type="text" value="42">
```

However, if I do this

```
{{ attr := templ.Attributes{"type": "text, "value": 42} }}
<input { attr... } />
```

only the `type` attribute will get rendered:

```
<input type="text">
```

The culprit seems to be `RenderAttributes` in `runtime.go`, which in its `switch` statement only type-checks for `string`, `*string`, `bool`, `*bool`, `KeyValue[string, bool]`, `KeyValue[bool, bool]` and `func() bool`.

**Expected behavior**

I'd expect attribute values stored in `templ.Attributes` (which is a `map[string]any` after all) to be treated the same as if I had specified them directly, that is passed through `fmt.Sprint` or something.

**`templ info` output**

```
(✓) os [ goos=linux goarch=amd64 ]
(✓) go [ location=/usr/local/go/bin/go version=go version go1.25rc1 linux/amd64 ]
(✓) gopls [ location=/home/chris/.local/bin/gopls version=golang.org/x/tools/gopls v0.19.1 ]
(✓) templ [ location=/home/chris/.local/bin/templ version=v0.3.906 ]
```


Discussion:
Ah, that's an oversight. Thanks for the detail in the issue, very clear.

The switch statement in https://github.com/a-h/templ/blob/23ccc900e24b2cd8da4320419d76c471b7b266b6/runtime.go#L459-L507 needs to be updated to include all types within the `templ.stringable` interface constraint: https://github.com/a-h/templ/blob/23ccc900e24b2cd8da4320419d76c471b7b266b6/runtime.go#L617-L619 and unit tests expanded to cover the behaviour.

The `RenderAttributes` function is currently tested by the `generator/test-spread-attributes` test, which is reasonable, but a code-level unit test would probably be easier to read and reason about.



Patch:
diff --git a/.version b/.version
index aa13a2462..c6aae95c3 100644
--- a/.version
+++ b/.version
@@ -1,1 +1,1 @@
-0.3.906
\ No newline at end of file
+0.3.908
\ No newline at end of file
diff --git a/runtime.go b/runtime.go
index 156fb091c..c804b0879 100644
--- a/runtime.go
+++ b/runtime.go
@@ -466,46 +466,82 @@ func RenderAttributes(ctx context.Context, w io.Writer, attributes Attributer) (
 				return err
 			}
 		case *string:
-			if value != nil {
-				if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(*value), `"`); err != nil {
-					return err
-				}
+			if value == nil {
+				continue
+			}
+			if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(*value), `"`); err != nil {
+				return err
 			}
 		case bool:
-			if value {
-				if err = writeStrings(w, ` `, EscapeString(key)); err != nil {
-					return err
-				}
+			if !value {
+				continue
+			}
+			if err = writeStrings(w, ` `, EscapeString(key)); err != nil {
+				return err
 			}
 		case *bool:
-			if value != nil && *value {
-				if err = writeStrings(w, ` `, EscapeString(key)); err != nil {
-					return err
-				}
+			if value == nil || !*value {
+				continue
+			}
+			if err = writeStrings(w, ` `, EscapeString(key)); err != nil {
+				return err
+			}
+		case int, int8, int16, int32, int64,
+			uint, uint8, uint16, uint32, uint64, uintptr,
+			float32, float64, complex64, complex128:
+			if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(fmt.Sprint(value)), `"`); err != nil {
+				return err
+			}
+		case *int, *int8, *int16, *int32, *int64,
+			*uint, *uint8, *uint16, *uint32, *uint64, *uintptr,
+			*float32, *float64, *complex64, *complex128:
+			value = ptrValue(value)
+			if value == nil {
+				continue
+			}
+			if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(fmt.Sprint(value)), `"`); err != nil {
+				return err
 			}
 		case KeyValue[string, bool]:
-			if value.Value {
-				if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(value.Key), `"`); err != nil {
-					return err
-				}
+			if !value.Value {
+				continue
+			}
+			if err = writeStrings(w, ` `, EscapeString(key), `="`, EscapeString(value.Key), `"`); err != nil {
+				return err
 			}
 		case KeyValue[bool, bool]:
-			if value.Value && value.Key {
-				if err = writeStrings(w, ` `, EscapeString(key)); err != nil {
-					return err
-				}
+			if !value.Value || !value.Key {
+				continue
+			}
+			if err = writeStrings(w, ` `, EscapeString(key)); err != nil {
+				return err
 			}
 		case func() bool:
-			if value() {
-				if err = writeStrings(w, ` `, EscapeString(key)); err != nil {
-					return err
-				}
+			if !value() {
+				continue
+			}
+			if err = writeStrings(w, ` `, EscapeString(key)); err != nil {
+				return err
 			}
 		}
 	}
 	return nil
 }
 
+func ptrValue(v any) any {
+	if v == nil {
+		return nil
+	}
+	rv := reflect.ValueOf(v)
+	if rv.Kind() != reflect.Ptr {
+		return v
+	}
+	if rv.IsNil() {
+		return nil
+	}
+	return rv.Elem().Interface()
+}
+
 // Context.
 
 type contextKeyType int


Test patch:
diff --git a/generator/test-spread-attributes/expected_numeric_attributes.html b/generator/test-spread-attributes/expected_numeric_attributes.html
new file mode 100644
index 000000000..822ec8b44
--- /dev/null
+++ b/generator/test-spread-attributes/expected_numeric_attributes.html
@@ -0,0 +1,5 @@
+<div>
+	<a bool-true complex-value="(1+2i)" float-value="3.14" int-value="42" int64-value="9223372036854775807" string-value="text" uint-value="100">text</a>
+	<div bool-true complex-value="(1+2i)" float-value="3.14" int-value="42" int64-value="9223372036854775807" string-value="text" uint-value="100">text2</div>
+	<div>text3</div>
+</div>
diff --git a/generator/test-spread-attributes/render_test.go b/generator/test-spread-attributes/render_test.go
index c637072d1..5eda11d4e 100644
--- a/generator/test-spread-attributes/render_test.go
+++ b/generator/test-spread-attributes/render_test.go
@@ -111,3 +111,28 @@ func nilPtr[T any]() *T {
 func ptr[T any](x T) *T {
 	return &x
 }
+
+//go:embed expected_numeric_attributes.html
+var expectedNumericAttributes string
+
+func TestNumericAttributeTypes(t *testing.T) {
+	t.Parallel()
+	component := BasicTemplate(templ.Attributes{
+		"int-value":     42,
+		"float-value":   3.14,
+		"uint-value":    uint(100),
+		"int64-value":   int64(9223372036854775807),
+		"complex-value": complex(1, 2),
+		"string-value":  "text",
+		"bool-true":     true,
+		"bool-false":    false,
+	})
+
+	diff, err := htmldiff.Diff(component, expectedNumericAttributes)
+	if err != nil {
+		t.Fatal(err)
+	}
+	if diff != "" {
+		t.Error(diff)
+	}
+}
diff --git a/runtime_test.go b/runtime_test.go
index 135ac9690..674d715c5 100644
--- a/runtime_test.go
+++ b/runtime_test.go
@@ -612,3 +612,178 @@ func TestNonce(t *testing.T) {
 		}
 	})
 }
+
+func TestRenderAttributes(t *testing.T) {
+	tests := []struct {
+		name       string
+		attributes templ.Attributes
+		expected   string
+	}{
+		{
+			name: "string attributes are rendered",
+			attributes: templ.Attributes{
+				"class": "test-class",
+				"id":    "test-id",
+			},
+			expected: ` class="test-class" id="test-id"`,
+		},
+		{
+			name: "integer types are rendered as strings",
+			attributes: templ.Attributes{
+				"int":   42,
+				"int8":  int8(8),
+				"int16": int16(16),
+				"int32": int32(32),
+				"int64": int64(64),
+			},
+			expected: ` int="42" int16="16" int32="32" int64="64" int8="8"`,
+		},
+		{
+			name: "unsigned integer types are rendered as strings",
+			attributes: templ.Attributes{
+				"uint":    uint(42),
+				"uint8":   uint8(8),
+				"uint16":  uint16(16),
+				"uint32":  uint32(32),
+				"uint64":  uint64(64),
+				"uintptr": uintptr(100),
+			},
+			expected: ` uint="42" uint16="16" uint32="32" uint64="64" uint8="8" uintptr="100"`,
+		},
+		{
+			name: "float types are rendered as strings",
+			attributes: templ.Attributes{
+				"float32": float32(3.14),
+				"float64": float64(2.718),
+			},
+			expected: ` float32="3.14" float64="2.718"`,
+		},
+		{
+			name: "complex types are rendered as strings",
+			attributes: templ.Attributes{
+				"complex64":  complex64(1 + 2i),
+				"complex128": complex128(3 + 4i),
+			},
+			expected: ` complex128="(3+4i)" complex64="(1+2i)"`,
+		},
+		{
+			name: "boolean attributes are rendered correctly",
+			attributes: templ.Attributes{
+				"checked":  true,
+				"disabled": false,
+			},
+			expected: ` checked`,
+		},
+		{
+			name: "mixed types are rendered correctly",
+			attributes: templ.Attributes{
+				"class":  "button",
+				"value":  42,
+				"width":  float64(100.5),
+				"hidden": false,
+				"active": true,
+			},
+			expected: ` active class="button" value="42" width="100.5"`,
+		},
+		{
+			name: "nil pointer attributes are not rendered",
+			attributes: templ.Attributes{
+				"optional": (*string)(nil),
+				"visible":  (*bool)(nil),
+			},
+			expected: ``,
+		},
+		{
+			name: "non-nil pointer attributes are rendered",
+			attributes: templ.Attributes{
+				"title":   ptr("test title"),
+				"enabled": ptr(true),
+			},
+			expected: ` enabled title="test title"`,
+		},
+		{
+			name: "numeric pointer types are rendered as strings",
+			attributes: templ.Attributes{
+				"int-ptr":        ptr(42),
+				"int8-ptr":       ptr(int8(8)),
+				"int16-ptr":      ptr(int16(16)),
+				"int32-ptr":      ptr(int32(32)),
+				"int64-ptr":      ptr(int64(64)),
+				"uint-ptr":       ptr(uint(42)),
+				"uint8-ptr":      ptr(uint8(8)),
+				"uint16-ptr":     ptr(uint16(16)),
+				"uint32-ptr":     ptr(uint32(32)),
+				"uint64-ptr":     ptr(uint64(64)),
+				"uintptr-ptr":    ptr(uintptr(100)),
+				"float32-ptr":    ptr(float32(3.14)),
+				"float64-ptr":    ptr(float64(2.718)),
+				"complex64-ptr":  ptr(complex64(1 + 2i)),
+				"complex128-ptr": ptr(complex128(3 + 4i)),
+			},
+			expected: ` complex128-ptr="(3+4i)" complex64-ptr="(1+2i)" float32-ptr="3.14" float64-ptr="2.718" int-ptr="42" int16-ptr="16" int32-ptr="32" int64-ptr="64" int8-ptr="8" uint-ptr="42" uint16-ptr="16" uint32-ptr="32" uint64-ptr="64" uint8-ptr="8" uintptr-ptr="100"`,
+		},
+		{
+			name: "nil numeric pointer attributes are not rendered",
+			attributes: templ.Attributes{
+				"int-ptr":       (*int)(nil),
+				"float32-ptr":   (*float32)(nil),
+				"complex64-ptr": (*complex64)(nil),
+			},
+			expected: ``,
+		},
+		{
+			name: "KeyValue[string, bool] attributes are rendered correctly",
+			attributes: templ.Attributes{
+				"data-value":  templ.KV("test-string", true),
+				"data-hidden": templ.KV("ignored", false),
+			},
+			expected: ` data-value="test-string"`,
+		},
+		{
+			name: "KeyValue[bool, bool] attributes are rendered correctly",
+			attributes: templ.Attributes{
+				"checked":  templ.KV(true, true),
+				"disabled": templ.KV(false, true),
+				"hidden":   templ.KV(true, false),
+			},
+			expected: ` checked`,
+		},
+		{
+			name: "function bool attributes are rendered correctly",
+			attributes: templ.Attributes{
+				"enabled": func() bool { return true },
+				"hidden":  func() bool { return false },
+			},
+			expected: ` enabled`,
+		},
+		{
+			name: "mixed KeyValue and function attributes",
+			attributes: templ.Attributes{
+				"data-name": templ.KV("value", true),
+				"active":    templ.KV(true, true),
+				"dynamic":   func() bool { return true },
+				"ignored":   templ.KV("ignored", false),
+			},
+			expected: ` active data-name="value" dynamic`,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			var buf bytes.Buffer
+			err := templ.RenderAttributes(context.Background(), &buf, tt.attributes)
+			if err != nil {
+				t.Fatalf("RenderAttributes failed: %v", err)
+			}
+
+			actual := buf.String()
+			if actual != tt.expected {
+				t.Errorf("expected %q, got %q", tt.expected, actual)
+			}
+		})
+	}
+}
+
+func ptr[T any](x T) *T {
+	return &x
+}