From 2e09f3d102d0af369db3a3524fb654bbce8e35ae Mon Sep 17 00:00:00 2001 From: Tyler Earls Date: Sat, 22 Nov 2025 12:12:10 -0600 Subject: [PATCH] fix: make class attribute overwrite behavior consistent between SSR and CSR (closes #4248) (#4439) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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`) - 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 --- leptos/tests/ssr.rs | 70 ++++++++++++++++++++++++++++++++++++++++ tachys/src/html/class.rs | 26 +++++++++++++++ 2 files changed, 96 insertions(+) diff --git a/leptos/tests/ssr.rs b/leptos/tests/ssr.rs index 999989937..2b8e62108 100644 --- a/leptos/tests/ssr.rs +++ b/leptos/tests/ssr.rs @@ -103,6 +103,76 @@ fn test_classes() { assert_eq!(rendered.to_html(), "
"); } +#[cfg(feature = "ssr")] +#[test] +fn test_class_with_class_directive_merge() { + use leptos::prelude::*; + + // class= followed by class: should merge + let rendered: View> = view! { +
+ }; + + assert_eq!(rendered.to_html(), "
"); +} + +#[cfg(feature = "ssr")] +#[test] +fn test_solo_class_directive() { + use leptos::prelude::*; + + // Solo class: directive should work without class attribute + let rendered: View> = view! { +
+ }; + + assert_eq!(rendered.to_html(), "
"); +} + +#[cfg(feature = "ssr")] +#[test] +fn test_class_directive_with_static_class() { + use leptos::prelude::*; + + // class:foo comes after class= due to macro sorting + // The class= clears buffer, then class:foo appends + let rendered: View> = view! { +
+ }; + + // After macro sorting: class="bar" class:foo=true + // Expected: "bar foo" + assert_eq!(rendered.to_html(), "
"); +} + +#[cfg(feature = "ssr")] +#[test] +fn test_global_class_applied() { + use leptos::prelude::*; + + // Test that a global class is properly applied + let rendered: View> = view! { class="global", +
+ }; + + assert_eq!(rendered.to_html(), "
"); +} + +#[cfg(feature = "ssr")] +#[test] +fn test_multiple_class_attributes_overwrite() { + use leptos::prelude::*; + + // When multiple class attributes are applied, the last one should win (browser behavior) + // This simulates what happens when attributes are combined programmatically + let el = leptos::html::div().class("first").class("second"); + + let html = el.to_html(); + + // The second class attribute should overwrite the first + assert_eq!(html, "
"); +} + #[cfg(feature = "ssr")] #[test] fn ssr_with_styles() { diff --git a/tachys/src/html/class.rs b/tachys/src/html/class.rs index ce3bf70c9..3fcad1167 100644 --- a/tachys/src/html/class.rs +++ b/tachys/src/html/class.rs @@ -57,6 +57,10 @@ where _style: &mut String, _inner_html: &mut String, ) { + // If this is a class="..." attribute (not class:name=value), clear previous value + if self.class.should_overwrite() { + class.clear(); + } class.push(' '); self.class.to_html(class); } @@ -156,6 +160,12 @@ pub trait IntoClass: Send { /// Renders the class to HTML. fn to_html(self, class: &mut String); + /// Whether this class attribute should overwrite previous class values. + /// Returns `true` for `class="..."` attributes, `false` for `class:name=value` directives. + fn should_overwrite(&self) -> bool { + false + } + /// Renders the class to HTML for a `