Skip to content

Commit 2e09f3d

Browse files
taearlsclaude
andauthored
fix: make class attribute overwrite behavior consistent between SSR and CSR (closes #4248) (#4439)
Fixes #4248 During SSR, multiple `class` attributes were incorrectly concatenating instead of overwriting like they do in browsers. This inconsistency caused code that appeared to work in SSR to fail in CSR/hydration. The fix distinguishes between two types of class attributes: - `class="..."` attributes should overwrite (clear previous values) - `class:name=value` directives should merge (append to existing classes) Implementation: - Added `should_overwrite()` method to `IntoClass` trait (defaults to `false`) - Modified `Class::to_html()` to clear buffer before rendering if `should_overwrite()` returns `true` - Implemented `should_overwrite() -> true` for string types (`&str`, `String`, `Cow<'_, str>`, `Arc<str>`) - Tuple type `(&'static str, bool)` keeps default `false` for merge behavior Added comprehensive tests to verify: - `class="foo" class:bar=true` produces `"foo bar"` (merge) - `class:foo=true` works standalone - Correct behavior with macro attribute sorting - Global class application 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent e6fe7fe commit 2e09f3d

File tree

2 files changed

+96
-0
lines changed

2 files changed

+96
-0
lines changed

leptos/tests/ssr.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,76 @@ fn test_classes() {
103103
assert_eq!(rendered.to_html(), "<div class=\"my big red car\"></div>");
104104
}
105105

106+
#[cfg(feature = "ssr")]
107+
#[test]
108+
fn test_class_with_class_directive_merge() {
109+
use leptos::prelude::*;
110+
111+
// class= followed by class: should merge
112+
let rendered: View<HtmlElement<_, _, _>> = view! {
113+
<div class="foo" class:bar=true></div>
114+
};
115+
116+
assert_eq!(rendered.to_html(), "<div class=\"foo bar\"></div>");
117+
}
118+
119+
#[cfg(feature = "ssr")]
120+
#[test]
121+
fn test_solo_class_directive() {
122+
use leptos::prelude::*;
123+
124+
// Solo class: directive should work without class attribute
125+
let rendered: View<HtmlElement<_, _, _>> = view! {
126+
<div class:foo=true></div>
127+
};
128+
129+
assert_eq!(rendered.to_html(), "<div class=\"foo\"></div>");
130+
}
131+
132+
#[cfg(feature = "ssr")]
133+
#[test]
134+
fn test_class_directive_with_static_class() {
135+
use leptos::prelude::*;
136+
137+
// class:foo comes after class= due to macro sorting
138+
// The class= clears buffer, then class:foo appends
139+
let rendered: View<HtmlElement<_, _, _>> = view! {
140+
<div class:foo=true class="bar"></div>
141+
};
142+
143+
// After macro sorting: class="bar" class:foo=true
144+
// Expected: "bar foo"
145+
assert_eq!(rendered.to_html(), "<div class=\"bar foo\"></div>");
146+
}
147+
148+
#[cfg(feature = "ssr")]
149+
#[test]
150+
fn test_global_class_applied() {
151+
use leptos::prelude::*;
152+
153+
// Test that a global class is properly applied
154+
let rendered: View<HtmlElement<_, _, _>> = view! { class="global",
155+
<div></div>
156+
};
157+
158+
assert_eq!(rendered.to_html(), "<div class=\"global\"></div>");
159+
}
160+
161+
#[cfg(feature = "ssr")]
162+
#[test]
163+
fn test_multiple_class_attributes_overwrite() {
164+
use leptos::prelude::*;
165+
166+
// When multiple class attributes are applied, the last one should win (browser behavior)
167+
// This simulates what happens when attributes are combined programmatically
168+
let el = leptos::html::div().class("first").class("second");
169+
170+
let html = el.to_html();
171+
172+
// The second class attribute should overwrite the first
173+
assert_eq!(html, "<div class=\"second\"></div>");
174+
}
175+
106176
#[cfg(feature = "ssr")]
107177
#[test]
108178
fn ssr_with_styles() {

tachys/src/html/class.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,10 @@ where
5757
_style: &mut String,
5858
_inner_html: &mut String,
5959
) {
60+
// If this is a class="..." attribute (not class:name=value), clear previous value
61+
if self.class.should_overwrite() {
62+
class.clear();
63+
}
6064
class.push(' ');
6165
self.class.to_html(class);
6266
}
@@ -156,6 +160,12 @@ pub trait IntoClass: Send {
156160
/// Renders the class to HTML.
157161
fn to_html(self, class: &mut String);
158162

163+
/// Whether this class attribute should overwrite previous class values.
164+
/// Returns `true` for `class="..."` attributes, `false` for `class:name=value` directives.
165+
fn should_overwrite(&self) -> bool {
166+
false
167+
}
168+
159169
/// Renders the class to HTML for a `<template>`.
160170
#[allow(unused)] // it's used with `nightly` feature
161171
fn to_template(class: &mut String) {}
@@ -289,6 +299,10 @@ impl IntoClass for &str {
289299
class.push_str(self);
290300
}
291301

302+
fn should_overwrite(&self) -> bool {
303+
true
304+
}
305+
292306
fn hydrate<const FROM_SERVER: bool>(
293307
self,
294308
el: &crate::renderer::types::Element,
@@ -346,6 +360,10 @@ impl IntoClass for Cow<'_, str> {
346360
IntoClass::to_html(&*self, class);
347361
}
348362

363+
fn should_overwrite(&self) -> bool {
364+
true
365+
}
366+
349367
fn hydrate<const FROM_SERVER: bool>(
350368
self,
351369
el: &crate::renderer::types::Element,
@@ -403,6 +421,10 @@ impl IntoClass for String {
403421
IntoClass::to_html(self.as_str(), class);
404422
}
405423

424+
fn should_overwrite(&self) -> bool {
425+
true
426+
}
427+
406428
fn hydrate<const FROM_SERVER: bool>(
407429
self,
408430
el: &crate::renderer::types::Element,
@@ -460,6 +482,10 @@ impl IntoClass for Arc<str> {
460482
IntoClass::to_html(self.as_ref(), class);
461483
}
462484

485+
fn should_overwrite(&self) -> bool {
486+
true
487+
}
488+
463489
fn hydrate<const FROM_SERVER: bool>(
464490
self,
465491
el: &crate::renderer::types::Element,

0 commit comments

Comments
 (0)