Skip to main content

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}