leptos_classes/into_class.rs
1use crate::Classes;
2use leptos::reactive::effect::RenderEffect;
3use leptos::tachys::{
4 html::class::IntoClass,
5 renderer::{Rndr, dom::Element},
6};
7use leptos::web_sys;
8
9const CLASS_ATTRIBUTE: &str = "class";
10
11#[doc(hidden)]
12#[derive(Clone)]
13pub struct Elem(web_sys::Element);
14
15impl Elem {
16 /// Reads the live `class` attribute from the underlying DOM element. The `web_sys` API copies
17 /// the JS string into a Rust `String`, requiring an allocation.
18 fn read_class_attribute(&self) -> String {
19 self.0.get_attribute(CLASS_ATTRIBUTE).unwrap_or_default()
20 }
21
22 /// Set `value` as the `class` attribute of `el`. Removes the attribute should `value` be empty.
23 fn set_class_attribute(&self, value: &str) {
24 if value.is_empty() {
25 self.remove_class_attribute();
26 } else {
27 Rndr::set_attribute(&self.0, CLASS_ATTRIBUTE, value);
28 }
29 }
30
31 fn remove_class_attribute(&self) {
32 Rndr::remove_attribute(&self.0, CLASS_ATTRIBUTE);
33 }
34}
35
36/// Reusable string buffers for rendering and diffing the `class` attribute.
37///
38/// `current` is the value we last wrote to the DOM and is treated as authoritative for what the
39/// attribute currently contains. `scratch` is a working buffer to which classes can be written. It
40/// only requires an in-memory comparison between (the new) `scratch` and (the old) `current` state
41/// to determine whether the materialized class string changed, in which case we flush to the DOM
42/// and `std::mem::swap` the two buffers. Both fields keep their capacity untouched, so this type
43/// performs little allocations once stable.
44#[doc(hidden)]
45#[derive(Default)]
46pub struct ClassBuffers {
47 current: String,
48 scratch: String,
49}
50
51impl ClassBuffers {
52 /// Diffs the freshly rendered class string against the last value written to the DOM and
53 /// flushes to the DOM only on change.
54 ///
55 /// `self.scratch` is cleared and re-rendered from `classes`; if it differs from `self.current`
56 /// the new string is written to `el` via `set_class_attribute`; finally the two buffers are
57 /// swapped so the freshly rendered string becomes `current` for the next call.
58 ///
59 /// Performance contract (relied on by the reactive `RenderEffect` path): this function must
60 /// stay free of DOM reads (no `getAttribute`) and free of per-tick allocations - both buffers
61 /// retain their capacity across calls via the swap, so `clear` + `write_active_classes` reuse
62 /// the existing backing storage. Adding a DOM read here would defeat the whole point of
63 /// caching `current` in memory; reallocating a fresh `String` per call would defeat the
64 /// buffer reuse.
65 fn sync_class_attribute(&mut self, classes: &Classes, el: &Elem) {
66 self.scratch.clear();
67 classes.write_active_classes(&mut self.scratch);
68
69 if self.scratch != self.current {
70 el.set_class_attribute(&self.scratch);
71 }
72
73 std::mem::swap(&mut self.current, &mut self.scratch);
74 }
75}
76
77/// Per-element render state held by Leptos for a `Classes` attribute.
78///
79/// `el` lives at the parent-struct level (rather than inside each [`ClassesKind`] variant) so
80/// that recovering the buffers on `rebuild` / `reset` does not have to clone or move `el` out of
81/// a variant. The render path only ever borrows `&self.el` while writing to `self.kind`, which
82/// the borrow checker permits because the two fields are disjoint. `rebuild` decides between
83/// the two kinds by re-checking `Classes::is_reactive()` on the *new* value, so each rebuild
84/// may flip kinds depending on the freshly produced `Classes`.
85#[doc(hidden)]
86pub struct ClassesState {
87 el: Elem,
88 kind: ClassesKind,
89}
90
91/// Variant-specific payload of [`ClassesState`].
92///
93/// `Static` holds the [`ClassBuffers`] directly. `Reactive` hides them inside the
94/// `RenderEffect`'s value, which the effect threads through each run via its `prev` argument
95/// so the allocations are reused across reactive ticks. The `Default` impl picks an empty
96/// `Static` so `take_buffers` can use `std::mem::take` to extract the live kind without ever
97/// having to clone `Elem`.
98enum ClassesKind {
99 Static {
100 buffers: ClassBuffers,
101 },
102 Reactive {
103 render_effect: RenderEffect<ClassBuffers>,
104 },
105}
106
107impl Default for ClassesKind {
108 fn default() -> Self {
109 Self::Static {
110 buffers: ClassBuffers::default(),
111 }
112 }
113}
114
115impl ClassesKind {
116 /// Builds the kind that matches `classes`'s reactivity, performing the initial
117 /// compare-and-flush against the supplied `buffers` (whose `current` is whatever the caller
118 /// considers authoritative for the live DOM attribute - empty for a fresh build, last-written
119 /// for a rebuild, DOM-seeded for an SSR hydrate). For the reactive arm, the closure clones
120 /// `el` once (the only clone that survives this constructor); for the static arm it borrows
121 /// `el` and writes through it directly.
122 fn build(classes: Classes, el: &Elem, mut buffers: ClassBuffers) -> Self {
123 if classes.is_reactive() {
124 let closure_el = el.clone();
125 Self::Reactive {
126 render_effect: RenderEffect::new_with_value(
127 move |prev| {
128 let mut buffers = prev.unwrap_or_default();
129 buffers.sync_class_attribute(&classes, &closure_el);
130 buffers
131 },
132 Some(buffers),
133 ),
134 }
135 } else {
136 buffers.sync_class_attribute(&classes, el);
137 Self::Static { buffers }
138 }
139 }
140}
141
142impl ClassesState {
143 fn new(classes: Classes, el: Elem, buffers: ClassBuffers) -> Self {
144 let kind = ClassesKind::build(classes, &el, buffers);
145 Self { el, kind }
146 }
147
148 /// Recovers the cached buffer pair regardless of which kind `self` currently holds. `Static`
149 /// hands the buffers over directly via `std::mem::take`; `Reactive` extracts them from the
150 /// effect's value via `take_value`. Either path leaves `self.kind` as the `Default`
151 /// (empty-Static) sentinel; the caller is expected to overwrite `self.kind` before any
152 /// subsequent render. `self.el` is untouched, so no clone is required.
153 fn take_buffers(&mut self) -> ClassBuffers {
154 // This take constructs a default kind to be put into place. This will be of kind `Static`,
155 // requiring no allocations, as `String::new` does not immediately allocate.
156 match std::mem::take(&mut self.kind) {
157 ClassesKind::Static { buffers } => buffers,
158 ClassesKind::Reactive { render_effect } => {
159 render_effect.take_value().unwrap_or_default()
160 }
161 }
162 }
163}
164
165impl IntoClass for Classes {
166 type AsyncOutput = Self;
167 type State = ClassesState;
168 type Cloneable = Self;
169 type CloneableOwned = Self;
170
171 fn html_len(&self) -> usize {
172 // Estimate is the sum of class names and required separator spaces.
173 self.estimated_class_len()
174 }
175
176 fn to_html(self, class: &mut String) {
177 // SSR path: build class string directly, avoiding intermediate allocations.
178 self.write_active_classes(class);
179 }
180
181 fn should_overwrite(&self) -> bool {
182 // `Classes` owns the whole `class` attribute!
183 true
184 }
185
186 fn hydrate<const FROM_SERVER: bool>(self, el: &Element) -> Self::State {
187 let el = Elem(el.clone());
188 let mut buffers = ClassBuffers::default();
189 if FROM_SERVER {
190 // Seed `current` with what the server rendered, so a matching client-side render can
191 // skip a redundant `set_class_attribute` call. This is the only DOM read in the
192 // entire lifecycle.
193 buffers.current = el.read_class_attribute();
194 }
195 ClassesState::new(self, el, buffers)
196 }
197
198 fn build(self, el: &Element) -> Self::State {
199 let el = Elem(el.clone());
200 ClassesState::new(self, el, ClassBuffers::default())
201 }
202
203 fn rebuild(self, state: &mut Self::State) {
204 // Single decision point for the new kind: ask the *new* `Classes` whether it is
205 // reactive, independent of which kind the cached state currently holds. The buffers are
206 // recovered uniformly from either kind so every (cached, new) transition - including
207 // Static<->Reactive flips when a `move || ...` closure swaps between reactive and
208 // non-reactive `Classes` across re-renders - goes through `ClassesKind::build`.
209 let buffers = state.take_buffers();
210 state.kind = ClassesKind::build(self, &state.el, buffers);
211 }
212
213 fn into_cloneable(self) -> Self::Cloneable {
214 self
215 }
216
217 fn into_cloneable_owned(self) -> Self::CloneableOwned {
218 self
219 }
220
221 fn dry_resolve(&mut self) {
222 // Touch all reactive values to register dependencies.
223 self.touch_reactive_dependencies();
224 }
225
226 async fn resolve(self) -> Self::AsyncOutput {
227 self
228 }
229
230 fn reset(state: &mut Self::State) {
231 let mut buffers = state.take_buffers();
232 buffers.current.clear();
233 state.el.remove_class_attribute();
234 state.kind = ClassesKind::Static { buffers };
235 }
236}
237
238#[cfg(test)]
239mod tests {
240 use assertr::prelude::*;
241 use leptos::tachys::html::class::IntoClass;
242
243 use crate::Classes;
244
245 #[test]
246 fn to_html_writes_active_tokens() {
247 let classes = Classes::builder().with("foo").with("bar").build();
248 let mut html = String::new();
249 classes.to_html(&mut html);
250 assert_that!(html).is_equal_to("foo bar".to_string());
251 }
252
253 #[test]
254 fn to_html_writes_nothing_when_empty() {
255 let classes = Classes::new();
256 let mut html = String::new();
257 classes.to_html(&mut html);
258 assert_that!(html).is_equal_to(String::new());
259 }
260
261 #[test]
262 fn to_html_skips_inactive_entries() {
263 let classes = Classes::builder()
264 .with_reactive("active", true)
265 .with_reactive("disabled", false)
266 .with_reactive("visible", true)
267 .build();
268 let mut html = String::new();
269 classes.to_html(&mut html);
270 assert_that!(html).is_equal_to("active visible".to_string());
271 }
272
273 #[test]
274 fn to_html_appends_to_nonempty_buffer() {
275 let classes = Classes::builder().with("new-class").build();
276 let mut html = String::from("existing");
277 classes.to_html(&mut html);
278 assert_that!(html).is_equal_to("existing new-class".to_string());
279 }
280
281 #[test]
282 fn should_overwrite_is_true() {
283 let classes = Classes::new();
284 assert_that!(classes.should_overwrite()).is_true();
285 }
286
287 #[test]
288 fn html_len_is_exact_for_all_single_entries() {
289 // The estimate is sum(name_len) + (n - 1) separators, which exactly matches the
290 // rendered length when every entry is a single always-active token.
291 let classes = Classes::builder().with("foo").with("bar").build();
292 let rendered = classes.clone().to_class_string();
293
294 assert_that!(classes.html_len()).is_equal_to(rendered.len());
295 }
296
297 #[test]
298 fn html_len_overshoots_toggle_by_inactive_branch_diff() {
299 // A toggle pair contributes max(when_true.len(), when_false.len()) to the estimate, so
300 // when the shorter branch is active the estimate overshoots by the length difference.
301 let classes = Classes::builder()
302 .with("base")
303 .with_toggle(false, "active-state", "off") // false branch "off" is active.
304 .build();
305 let rendered = classes.clone().to_class_string();
306
307 let longer_branch = "active-state".len();
308 let active_branch = "off".len();
309 let expected_overshoot = longer_branch - active_branch;
310
311 assert_that!(classes.html_len()).is_equal_to(rendered.len() + expected_overshoot);
312 }
313}