Skip to main content

euv_core/vdom/attribute/
impl.rs

1use super::*;
2
3/// Implementation of the public read side of an attribute entry.
4impl AttributeEntry {
5    /// Returns the CSS class names this attribute carries.
6    ///
7    /// The `class` attribute is the one place where a component's
8    /// styling decisions become observable, so it needs a public read
9    /// side: a component library's tests have to be able to assert
10    /// that a state actually landed on the right class, and an external
11    /// crate cannot read the raw attribute value.
12    ///
13    /// Every shape the attribute can take is reported:
14    /// `Css` and `CssRef` contribute the class name they carry, `Text`
15    /// and `StaticText` contribute their string split on whitespace,
16    /// a `Signal` contributes the class list it currently holds — that
17    /// is the shape a reactive `class:` attribute takes — and the
18    /// remaining shapes, an event handler, contribute nothing, because
19    /// neither is a class list.
20    ///
21    /// # Returns
22    ///
23    /// - `Vec<String>` - The class names, in the order they appear.
24    pub fn class_names(&self) -> Vec<String> {
25        match self.get_value() {
26            AttributeValue::Css(css) => vec![css.get_name().clone()],
27            AttributeValue::CssRef(css) => vec![css.get_name().clone()],
28            AttributeValue::Text(text) => split_class_names(text),
29            AttributeValue::StaticText(text) => split_class_names(text),
30            AttributeValue::Signal(signal) => split_class_names(&signal.get()),
31            _ => Vec::new(),
32        }
33    }
34
35    /// Returns the attribute's value as text, when it carries one.
36    ///
37    /// A `Text` or `StaticText` attribute is its own value; a `Css` or
38    /// `CssRef` attribute contributes its class name, because that is
39    /// the text the browser ends up showing for it. The remaining
40    /// shapes — a reactive signal, an event handler — carry no text.
41    ///
42    /// # Returns
43    ///
44    /// - `Option<String>` - The value, when the attribute has one.
45    pub fn text_value(&self) -> Option<String> {
46        match self.get_value() {
47            AttributeValue::Text(text) => Some(text.clone()),
48            AttributeValue::StaticText(text) => Some((*text).to_string()),
49            AttributeValue::Css(css) => Some(css.get_name().clone()),
50            AttributeValue::CssRef(css) => Some(css.get_name().clone()),
51            _ => None,
52        }
53    }
54}
55
56/// Implementation of injected class tracking for CSS deduplication.
57impl InjectedClassesCell {
58    /// Returns `true` if the given class name has already been injected into
59    /// the DOM.
60    ///
61    /// # Arguments
62    ///
63    /// - `&str` - The CSS class name to check.
64    ///
65    /// # Returns
66    ///
67    /// - `bool` - Whether the class name has been injected.
68    pub(crate) fn is_injected(class_name: &str) -> bool {
69        INJECTED_CLASSES
70            .read()
71            .map(|classes: RwLockReadGuard<'_, HashSet<String>>| classes.contains(class_name))
72            .unwrap_or(false)
73    }
74
75    /// Marks a class name as injected so future calls to `is_injected`
76    /// return `true`.
77    ///
78    /// A poisoned lock (which only happens if another thread panicked while
79    /// holding the write guard) is recovered from rather than propagated:
80    /// the set is deduplication bookkeeping, so losing a lock must never
81    /// take down the render path.
82    ///
83    /// # Arguments
84    ///
85    /// - `&str` - The CSS class name to mark as injected.
86    pub(crate) fn mark_injected(class_name: &str) {
87        let write_result: Result<
88            InjectedClassesWriteGuard<'_>,
89            PoisonError<InjectedClassesWriteGuard<'_>>,
90        > = INJECTED_CLASSES.write();
91        let Ok(mut classes) = write_result else {
92            return;
93        };
94        classes.insert(class_name.to_string());
95    }
96}
97
98/// Implementation of attribute value factory methods for reactive and merged values.
99impl AttributeValue {
100    /// Creates a reactive attribute `Self` for conditional attribute values.
101    ///
102    /// This function replaces the inline `Signal::create(...)` boilerplate
103    /// that was previously generated by the `html!` macro for every
104    /// attribute value containing an `if` condition.
105    ///
106    /// The returned `Self::Text` is a snapshot computed on each render of
107    /// the enclosing dynamic node: the signals read inside
108    /// `compute` during that render register the dynamic node as their
109    /// dependent, so any source change re-renders the node and rebuilds
110    /// this attribute with the new value. No signal slot or registry
111    /// listener is allocated for the snapshot. Direct `Self::Signal`
112    /// bindings retain their independent DOM subscription behavior.
113    ///
114    /// # Arguments
115    ///
116    /// - `F` - A closure that computes the current attribute value.
117    ///   Called once per render of the enclosing dynamic node.
118    ///
119    /// # Returns
120    ///
121    /// - `Self` - A `Self::Text` carrying the freshly computed value.
122    pub fn reactive<F>(compute: F) -> Self
123    where
124        F: Fn() -> String + 'static,
125    {
126        Self::Text(compute())
127    }
128
129    /// Merges multiple class attribute values into a single `Self`.
130    ///
131    /// Each input value is adapted into a `Self` via `IntoReactiveValue`.
132    /// `Css` values are injected into the DOM and their names are collected.
133    /// All non-empty class names are joined with spaces into a final `Text` attribute.
134    /// Signal inputs are read without allocating a new signal for the
135    /// merged snapshot. The enclosing dynamic node re-renders on any
136    /// constituent signal change (see
137    /// [`AttributeValue::reactive`]).
138    ///
139    /// # Arguments
140    ///
141    /// - `&[Self]` - The class attribute values to merge.
142    ///
143    /// # Returns
144    ///
145    /// - `Self` - A merged attribute value containing space-separated class names.
146    pub fn merge_class(values: &[Self]) -> Self {
147        Self::Text(Self::join_class_segments(values))
148    }
149
150    /// Joins class attribute values into a single space-separated string.
151    ///
152    /// OPT-20: builds the result in a single `String` (amortized growth,
153    /// no intermediate `Vec<String>` + `join(" ")` round-trip), iterating
154    /// the input values by reference and cloning only the segments that
155    /// survive the filter (skipping `_ => None` arms and empty `Text`
156    /// strings), so the per-class render allocation drops from `N + 2`
157    /// to `1`.
158    ///
159    /// OPT-11: both `Css` and `CssRef` arms inject the style on first
160    /// reference. Without the `CssRef` arm the ref would fall through
161    /// to `_ => None` and silently drop out of the merged class list —
162    /// the regression that wiped every multi-class CssRef entry (e.g.
163    /// the `c_binding_slider` class next to a parameterized
164    /// `c_slider_value("30%")` on the same `<input>`).
165    ///
166    /// # Arguments
167    ///
168    /// - `&[Self]` - The class attribute values to merge, in order.
169    ///
170    /// # Returns
171    ///
172    /// - `String` - The merged class list, segments separated by a
173    ///   single space; empty when no segment survived filtering.
174    fn join_class_segments(values: &[Self]) -> String {
175        let mut joined: String = String::new();
176        for value in values.iter() {
177            let segment: Cow<'_, str> = match value {
178                Self::Css(css) => {
179                    css.inject_style();
180                    let name: &str = css.get_name();
181                    if name.is_empty() {
182                        continue;
183                    }
184                    Cow::Borrowed(name)
185                }
186                Self::CssRef(css) => {
187                    css.inject_style();
188                    let name: &str = css.get_name();
189                    if name.is_empty() {
190                        continue;
191                    }
192                    Cow::Borrowed(name)
193                }
194                Self::Text(text_value) => {
195                    if text_value.is_empty() {
196                        continue;
197                    }
198                    Cow::Borrowed(text_value.as_str())
199                }
200                Self::Signal(signal) => {
201                    let current: String = signal.get();
202                    if current.is_empty() {
203                        continue;
204                    }
205                    Cow::Owned(current)
206                }
207                _ => continue,
208            };
209            if !joined.is_empty() {
210                joined.push(' ');
211            }
212            joined.push_str(segment.as_ref());
213        }
214        joined
215    }
216
217    /// Merges multiple style attribute values into a single `Self`.
218    ///
219    /// Each input value is expected to be a style string (`Text`) or a reactive
220    /// `Signal<String>` producing a style string. All non-empty style strings are
221    /// joined with spaces into a final combined style attribute.
222    /// Signal inputs are read without allocating a new signal for the
223    /// merged snapshot. The enclosing dynamic node re-renders on any
224    /// constituent signal change (see
225    /// [`AttributeValue::reactive`]).
226    ///
227    /// # Arguments
228    ///
229    /// - `&[Self]` - The style attribute values to merge.
230    ///
231    /// # Returns
232    ///
233    /// - `Self` - A merged attribute value containing the combined CSS style string.
234    pub fn merge_style(values: &[Self]) -> Self {
235        Self::Text(Self::join_style_segments(values))
236    }
237
238    /// Joins style attribute values into a single space-separated string.
239    ///
240    /// OPT-20: same single-`String` approach as `join_class_segments`
241    /// (amortized growth), specialised to the `Text` + `Signal` cases that
242    /// style merging uses. Avoids the intermediate `Vec<String>` plus
243    /// `join(" ")` round-trip of the previous implementation, dropping
244    /// per-render allocation count from `N + 2` to `1`.
245    ///
246    /// # Arguments
247    ///
248    /// - `&[Self]` - The style attribute values to merge, in order.
249    ///
250    /// # Returns
251    ///
252    /// - `String` - The merged CSS declarations, segments separated by a
253    ///   single space; empty when no segment carried a declaration.
254    fn join_style_segments(values: &[Self]) -> String {
255        let mut joined: String = String::new();
256        for value in values.iter() {
257            let segment: Cow<'_, str> = match value {
258                Self::Text(text_value) => {
259                    if text_value.is_empty() {
260                        continue;
261                    }
262                    Cow::Borrowed(text_value.as_str())
263                }
264                Self::Signal(signal) => {
265                    let current: String = signal.get();
266                    if current.is_empty() {
267                        continue;
268                    }
269                    Cow::Owned(current)
270                }
271                _ => continue,
272            };
273            if !joined.is_empty() {
274                joined.push(' ');
275            }
276            joined.push_str(segment.as_ref());
277        }
278        joined
279    }
280
281    /// Converts a bool signal into a reactive boolean attribute value.
282    ///
283    /// Produces `AttributeValue::BoolSignal` directly: the renderer writes
284    /// `"true"` / `"false"` and subscribes the source signal to the element
285    /// with no intermediate mapping signal (the previous `Signal<String>`
286    /// bridge) and no per-render subscription.
287    ///
288    /// # Arguments
289    ///
290    /// - `Signal<bool>` - The source boolean signal.
291    ///
292    /// # Returns
293    ///
294    /// - `AttributeValue` - A `BoolSignal` wrapping the source signal.
295    pub(crate) fn bool_to_attr(source: Signal<bool>) -> AttributeValue {
296        AttributeValue::BoolSignal(source)
297    }
298}
299
300/// Visual equality comparison for attribute values.
301///
302/// Compares values by their visual output rather than identity. `Signal`
303/// values are compared by their current resolved string (borrow-compared
304/// via `Signal::with`, no `String` clones); when both signals share the
305/// same slab slot, they are always considered **unequal** — the patch walk
306/// recognises that case separately and skips the DOM write because the
307/// mount-time signal binding already owns the attribute's DOM value (see
308/// `patch_attributes`), while the unequal verdict keeps `visual_eq` from
309/// masking a value change between VDOM snapshots.
310/// `Event` values are always considered equal (re-binding is handled by the
311/// handler registry), and `Css` values are compared by class name.
312impl PartialEq for AttributeValue {
313    /// Compares two attribute values for visual equality.
314    ///
315    /// # Arguments
316    ///
317    /// - `&Self` - The first attribute value.
318    /// - `&Self` - The second attribute value.
319    ///
320    /// # Returns
321    ///
322    /// - `bool` - `true` if the values are visually equal.
323    fn eq(&self, other: &Self) -> bool {
324        match (self, other) {
325            (Self::Text(old_value), Self::Text(new_value)) => old_value == new_value,
326            (Self::StaticText(old_value), Self::StaticText(new_value)) => old_value == new_value,
327            (Self::Text(old_value), Self::StaticText(new_value)) => old_value == new_value,
328            (Self::StaticText(old_value), Self::Text(new_value)) => old_value == new_value,
329            (Self::Signal(old_signal), Self::Signal(new_signal)) => {
330                if old_signal.get_inner() == new_signal.get_inner() {
331                    return false;
332                }
333                old_signal.with(|old_value: &String| {
334                    new_signal.with(|new_value: &String| old_value == new_value)
335                })
336            }
337            (Self::Signal(old_signal), Self::Text(new_value)) => {
338                old_signal.with(|old_value: &String| old_value == new_value)
339            }
340            (Self::Text(old_value), Self::Signal(new_signal)) => {
341                new_signal.with(|new_value: &String| old_value == new_value)
342            }
343            (Self::BoolSignal(old_signal), Self::BoolSignal(new_signal)) => {
344                if old_signal.get_inner() == new_signal.get_inner() {
345                    return false;
346                }
347                old_signal.get() == new_signal.get()
348            }
349            (Self::Event(_), Self::Event(_)) => true,
350            (Self::Css(old_class), Self::Css(new_class)) => {
351                old_class.get_name() == new_class.get_name()
352            }
353            (Self::CssRef(old_class), Self::CssRef(new_class)) => {
354                old_class.get_name() == new_class.get_name()
355            }
356            (Self::CssRef(old_class), Self::Css(new_class)) => {
357                old_class.get_name() == new_class.get_name()
358            }
359            (Self::Css(old_class), Self::CssRef(new_class)) => {
360                old_class.get_name() == new_class.get_name()
361            }
362            (Self::Dynamic(old_dynamic), Self::Dynamic(new_dynamic)) => old_dynamic == new_dynamic,
363            _ => false,
364        }
365    }
366}
367
368/// Visual equality comparison for attribute entries.
369///
370/// Two attribute entries are equal when their names match and their values
371/// are visually equal as defined by `AttributeValue::eq`.
372impl PartialEq for AttributeEntry {
373    /// Compares two attribute entries for visual equality.
374    ///
375    /// # Arguments
376    ///
377    /// - `&Self` - The first attribute entry.
378    /// - `&Self` - The second attribute entry.
379    ///
380    /// # Returns
381    ///
382    /// - `bool` - `true` if both names and values match.
383    fn eq(&self, other: &Self) -> bool {
384        self.get_name() == other.get_name() && self.get_value() == other.get_value()
385    }
386}
387
388/// Visual equality comparison for CSS classes.
389///
390/// Two CSS classes are considered equal when their class names match,
391/// since the name uniquely identifies the visual style rule.
392impl PartialEq for Css {
393    /// Compares two CSS classes by name.
394    ///
395    /// # Arguments
396    ///
397    /// - `&Self` - The first CSS class.
398    /// - `&Self` - The second CSS class.
399    ///
400    /// # Returns
401    ///
402    /// - `bool` - `true` if the class names match.
403    fn eq(&self, other: &Self) -> bool {
404        self.get_name() == other.get_name()
405    }
406}
407
408/// Implementation of Css construction and style injection.
409impl Css {
410    /// Parses pseudo-class/pseudo-element rules from a compact serialization string.
411    ///
412    /// The serialization format is: `:selector { key: value; key: value; }:another { ... }`
413    /// This is used by the `class!` macro for fully static class definitions
414    /// where pseudo rules can be computed at compile time.
415    ///
416    /// # Arguments
417    ///
418    /// - `I` - The serialized pseudo rules string.
419    ///
420    /// # Returns
421    ///
422    /// - `Vec<PseudoRule>` - The parsed pseudo rules.
423    pub fn parse_pseudo_rules<I>(input: I) -> Vec<PseudoRule>
424    where
425        I: AsRef<str>,
426    {
427        let mut remaining: &str = input.as_ref();
428        let mut rules: Vec<PseudoRule> = Vec::new();
429        while !remaining.is_empty() {
430            let selector_end: Option<usize> = remaining.find(CSS_RULE_OPEN);
431            let Some(selector_end_index) = selector_end else {
432                break;
433            };
434            let selector: &str = &remaining[..selector_end_index];
435            let after_selector: &str = remaining[selector_end_index..]
436                .strip_prefix(CSS_RULE_OPEN)
437                .unwrap_or_default();
438            let style_end: Option<usize> = after_selector.find(CHAR_CSS_RULE_CLOSE);
439            let Some(style_end_index) = style_end else {
440                break;
441            };
442            let style: &str = &after_selector[..style_end_index];
443            if !selector.is_empty() && !style.is_empty() {
444                rules.push(PseudoRule::new(selector.to_string(), style.to_string()));
445            }
446            remaining = after_selector[style_end_index..]
447                .strip_prefix(CHAR_CSS_RULE_CLOSE)
448                .unwrap_or_default();
449        }
450        rules
451    }
452
453    /// Parses media query rules from a compact serialization string.
454    ///
455    /// The serialization format is:
456    /// `@media query { key: value; ::selector { key: value; } }@media query2 { ... }`
457    /// This is used by the `class!` macro for fully static class definitions
458    /// where media rules can be computed at compile time.
459    /// Supports nested pseudo-element blocks inside media query blocks.
460    ///
461    /// # Arguments
462    ///
463    /// - `S` - The serialized media rules string.
464    ///
465    /// # Returns
466    ///
467    /// - `Vec<MediaRule>` - The parsed media rules.
468    pub fn parse_media_rules<S>(input: S) -> Vec<MediaRule>
469    where
470        S: AsRef<str>,
471    {
472        let input: &str = input.as_ref();
473        let mut rules: Vec<MediaRule> = Vec::new();
474        let mut remaining: &str = input;
475        while !remaining.is_empty() {
476            if !remaining.starts_with(CSS_MEDIA_PREFIX) {
477                break;
478            }
479            let after_prefix: &str = remaining.strip_prefix(CSS_MEDIA_PREFIX).unwrap_or_default();
480            let query_end: Option<usize> = after_prefix.find(CSS_RULE_OPEN);
481            let Some(query_end_index) = query_end else {
482                break;
483            };
484            let query: &str = &after_prefix[..query_end_index];
485            let after_query: &str = after_prefix[query_end_index..]
486                .strip_prefix(CSS_RULE_OPEN)
487                .unwrap_or_default();
488            let mut depth: usize = 1;
489            let mut close_pos: usize = 0;
490            for (index, char_value) in after_query.char_indices() {
491                if char_value == '{' {
492                    depth += 1;
493                } else if char_value == '}' {
494                    depth -= 1;
495                    if depth == 0 {
496                        close_pos = index;
497                        break;
498                    }
499                }
500            }
501            if close_pos == 0 {
502                break;
503            }
504            let body: &str = &after_query[..close_pos];
505            let (style, pseudo_rules): (String, Vec<PseudoRule>) = Self::parse_media_body(body);
506            if !query.is_empty() && (!style.is_empty() || !pseudo_rules.is_empty()) {
507                rules.push(MediaRule::new(query.to_string(), style, pseudo_rules));
508            }
509            remaining = after_query[close_pos..]
510                .strip_prefix(CHAR_CSS_RULE_CLOSE)
511                .unwrap_or_default();
512        }
513        rules
514    }
515
516    /// Parses the body of a media rule, separating top-level style declarations
517    /// from nested pseudo-element blocks.
518    ///
519    /// # Arguments
520    ///
521    /// - `&str` - The media rule body content (between the outer braces).
522    ///
523    /// # Returns
524    ///
525    /// - `(String, Vec<PseudoRule>)` - A tuple of the style string and pseudo rules.
526    fn parse_media_body(body: &str) -> (String, Vec<PseudoRule>) {
527        let mut style_parts: String = String::new();
528        let mut pseudo_rules: Vec<PseudoRule> = Vec::new();
529        let mut remaining: &str = body;
530        while !remaining.is_empty() {
531            let brace_pos: Option<usize> = remaining.find('{');
532            match brace_pos {
533                Some(pos) => {
534                    let before_brace: &str = remaining[..pos].trim();
535                    if before_brace.starts_with("::") || before_brace.starts_with(':') {
536                        let selector: &str = before_brace;
537                        let after_brace: &str = &remaining[pos + 1..];
538                        let mut depth: usize = 1;
539                        let mut close_pos: usize = 0;
540                        for (index, char_value) in after_brace.char_indices() {
541                            if char_value == '{' {
542                                depth += 1;
543                            } else if char_value == '}' {
544                                depth -= 1;
545                                if depth == 0 {
546                                    close_pos = index;
547                                    break;
548                                }
549                            }
550                        }
551                        if close_pos > 0 {
552                            let inner_style: &str = after_brace[..close_pos].trim();
553                            if !selector.is_empty() && !inner_style.is_empty() {
554                                pseudo_rules.push(PseudoRule::new(
555                                    selector.to_string(),
556                                    inner_style.to_string(),
557                                ));
558                            }
559                            remaining = after_brace[close_pos + 1..].trim_start();
560                            continue;
561                        }
562                        break;
563                    } else {
564                        style_parts.push_str(before_brace);
565                        style_parts.push(' ');
566                        let after_brace: &str = &remaining[pos + 1..];
567                        let mut depth: usize = 1;
568                        let mut close_pos: usize = 0;
569                        for (index, char_value) in after_brace.char_indices() {
570                            if char_value == '{' {
571                                depth += 1;
572                            } else if char_value == '}' {
573                                depth -= 1;
574                                if depth == 0 {
575                                    close_pos = index;
576                                    break;
577                                }
578                            }
579                        }
580                        if close_pos > 0 {
581                            style_parts.push_str(after_brace[..close_pos].trim());
582                            style_parts.push(' ');
583                            remaining = after_brace[close_pos + 1..].trim_start();
584                            continue;
585                        }
586                        break;
587                    }
588                }
589                None => {
590                    style_parts.push_str(remaining.trim());
591                    break;
592                }
593            }
594        }
595        (style_parts.trim().to_string(), pseudo_rules)
596    }
597
598    /// Injects this class's styles into the DOM if not already present.
599    ///
600    /// Uses a global `HashSet` to track injected class names, avoiding the
601    /// expensive `existing_css.contains(css)` full-text search on every call.
602    /// Builds the class rule, pseudo-class rules, and media rules as CSS text,
603    /// then appends them directly to the `<style>` element via
604    /// `append_child` with a new text node — no read-modify-write of the
605    /// entire stylesheet content.
606    ///
607    /// # Panics
608    ///
609    /// Panics if `window()` or `document()` is unavailable on the current platform.
610    pub fn inject_style(&self) {
611        let class_name: &String = self.get_name();
612        if InjectedClassesCell::is_injected(class_name) {
613            return;
614        }
615        InjectedClassesCell::mark_injected(class_name);
616        let raw_name: String = self.get_name().clone();
617        let mut escaped_name: String = String::with_capacity(raw_name.len() * 2);
618        for ch in raw_name.chars() {
619            if ch.is_ascii_alphanumeric() || ch == CHAR_HYPHEN || ch == CHAR_UNDERSCORE {
620                escaped_name.push(ch);
621            } else {
622                escaped_name.push(CHAR_CSS_ESCAPE);
623                escaped_name.push(ch);
624            }
625        }
626        let mut css_text: String = format!(
627            "{CHAR_CSS_CLASS_PREFIX}{escaped_name}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
628            self.get_style()
629        );
630        for pseudo_rule in self.get_pseudo_rules() {
631            if !pseudo_rule.get_style().is_empty() {
632                css_text = format!(
633                    "{css_text}{CHAR_CSS_RULE_SEPARATOR}{CHAR_CSS_CLASS_PREFIX}{}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
634                    escaped_name,
635                    pseudo_rule.get_selector(),
636                    pseudo_rule.get_style()
637                );
638            }
639        }
640        for media_rule in self.get_media_rules() {
641            if !media_rule.get_query().is_empty() {
642                let mut media_body: String = format!(
643                    "{CHAR_CSS_CLASS_PREFIX}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
644                    escaped_name,
645                    media_rule.get_style()
646                );
647                for pseudo_rule in media_rule.get_pseudo_rules() {
648                    if !pseudo_rule.get_style().is_empty() {
649                        media_body = format!(
650                            "{media_body} {CHAR_CSS_CLASS_PREFIX}{}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
651                            escaped_name,
652                            pseudo_rule.get_selector(),
653                            pseudo_rule.get_style()
654                        );
655                    }
656                }
657                css_text = format!(
658                    "{css_text}{CHAR_CSS_RULE_SEPARATOR}{CSS_MEDIA_PREFIX}{}{CSS_RULE_OPEN_FORMAT}{}{CSS_RULE_CLOSE_FORMAT}",
659                    media_rule.get_query(),
660                    media_body
661                );
662            }
663        }
664        Self::append_css(&css_text);
665    }
666
667    /// Appends CSS text directly to the shared `<style>` element.
668    ///
669    /// Creates a new text node and appends it as a child of the `<style>`
670    /// element, avoiding the read-modify-write pattern of reading the entire
671    /// `innerText`, concatenating, and setting it back.
672    ///
673    /// # Arguments
674    ///
675    /// - `&str` - The CSS text to append.
676    ///
677    /// The `cfg!` guard matches the one `Scheduler::current_time` already
678    /// uses: `window()` reads a wasm-only imported static, so on a non-wasm
679    /// target it panics with "cannot access imported statics on non-wasm
680    /// targets" rather than returning `None`. That panic short-circuits the
681    /// graceful `match window() { None => return }` below, and it fires from
682    /// `OnceLock<Css>::get_or_init` — the first time any `c_*()` class
683    /// function is called. Every styled view component calls one, so
684    /// without this guard none of them can be rendered in a native
685    /// `cargo test` process. On wasm the guard is a constant `true` and
686    /// this function behaves exactly as before.
687    fn append_css(css_text: &str) {
688        if !cfg!(target_arch = "wasm32") {
689            return;
690        }
691        let style_id: &str = EUV_CSS_INJECTED_ID;
692        let window_value: Window = match window() {
693            Some(window_instance) => window_instance,
694            None => return,
695        };
696        let document: Document = match window_value.document() {
697            Some(document_instance) => document_instance,
698            None => return,
699        };
700        let style_element: HtmlStyleElement = match document.get_element_by_id(style_id) {
701            Some(existing_element) => match existing_element.dyn_into::<HtmlStyleElement>() {
702                Ok(element) => element,
703                Err(_err) => return,
704            },
705            None => {
706                let created: Element = match document.create_element(STYLE_TAG) {
707                    Ok(element) => element,
708                    Err(_err) => return,
709                };
710                let style_element_from_id: HtmlStyleElement =
711                    match created.dyn_into::<HtmlStyleElement>() {
712                        Ok(element) => element,
713                        Err(_err) => return,
714                    };
715                style_element_from_id.set_id(style_id);
716                if let Some(head) = document.head() {
717                    let _: Result<Node, JsValue> = head.append_child(&style_element_from_id);
718                }
719                style_element_from_id
720            }
721        };
722        if !css_text.is_empty() {
723            let text_node: Text = document.create_text_node(css_text);
724            let _: Result<Node, JsValue> = style_element.append_child(&text_node);
725        }
726    }
727
728    /// Builds a CSS style string from an array of key-value pairs.
729    ///
730    /// This function is used by the `html!` macro to convert static `style:`
731    /// attributes into a CSS string without allocating intermediate objects.
732    ///
733    /// # Arguments
734    ///
735    /// - `&[(K, V)]` - An array of CSS property name-value pairs.
736    ///
737    /// # Returns
738    ///
739    /// - `String` - The CSS string (e.g., `"margin: 0 auto; max-width: 800px;"`).
740    pub fn style_string<K, V>(props: &[(K, V)]) -> String
741    where
742        K: AsRef<str>,
743        V: AsRef<str>,
744    {
745        // Single pre-sized `String` instead of per-prop `format!` +
746        // intermediate `Vec<String>` + `join` (was N + 2 allocations).
747        let total_len: usize = props
748            .iter()
749            .map(|(key, value): &(K, V)| {
750                key.as_ref().len() + CSS_PROP_SEPARATOR.len() + value.as_ref().len() + 2
751            })
752            .sum();
753        let mut out: String = String::with_capacity(total_len);
754        for (index, (key, value)) in props.iter().enumerate() {
755            if index > 0 {
756                out.push_str(CHAR_SPACE);
757            }
758            out.push_str(key.as_ref());
759            out.push_str(CSS_PROP_SEPARATOR);
760            out.push_str(value.as_ref());
761            out.push(CHAR_CSS_DECL_TERMINATOR);
762        }
763        out
764    }
765
766    /// Builds a stable suffix for a class name from a dynamic parameter value.
767    ///
768    /// Used by the `class!` macro when a parameter is wrapped in `{}` in the
769    /// class body. Wrapping a parameter opts it into value-dependent class
770    /// names, so each distinct value can inject its own CSS rule.
771    ///
772    /// # Arguments
773    ///
774    /// - `&str` - The dynamic parameter value.
775    ///
776    /// # Returns
777    ///
778    /// - `String` - A stable hexadecimal suffix for the class name.
779    pub fn param_class_name(value: &str) -> String {
780        let mut hash: u64 = CLASS_PARAM_HASH_FNV_OFFSET;
781        for byte in value.as_bytes() {
782            hash ^= u64::from(*byte);
783            hash = hash.wrapping_mul(CLASS_PARAM_HASH_FNV_PRIME);
784        }
785        format!("{hash:x}")
786    }
787
788    /// Builds a CSS style string from owned key-value pairs.
789    ///
790    /// Used by the `html!` macro for reactive style attributes (with `if`
791    /// conditions) where values are computed at runtime.
792    ///
793    /// # Arguments
794    ///
795    /// - `&[(String, String)]` - An array of owned CSS property name-value pairs.
796    ///
797    /// # Returns
798    ///
799    /// - `String` - The CSS string (e.g., `"margin: 0 auto; max-width: 800px;"`).
800    pub fn style_string_owned(props: &[(String, String)]) -> String {
801        // Single pre-sized `String` (same shape as `style_string`).
802        let total_len: usize = props
803            .iter()
804            .map(|(key, value): &(String, String)| {
805                key.len() + CSS_PROP_SEPARATOR.len() + value.len() + 2
806            })
807            .sum();
808        let mut out: String = String::with_capacity(total_len);
809        for (index, (key, value)) in props.iter().enumerate() {
810            if index > 0 {
811                out.push_str(CHAR_SPACE);
812            }
813            out.push_str(key);
814            out.push_str(CSS_PROP_SEPARATOR);
815            out.push_str(value);
816            out.push(CHAR_CSS_DECL_TERMINATOR);
817        }
818        out
819    }
820
821    /// Injects CSS text into the shared `<style>` element in the DOM.
822    ///
823    /// Delegates to [`Css::append_css`] for the actual DOM append.
824    /// Unlike the previous implementation, this does not read the existing
825    /// stylesheet content or perform a full-text `contains` search.
826    ///
827    /// # Arguments
828    ///
829    /// - `S` - The CSS text to inject (e.g., reset styles, keyframes, media queries).
830    ///
831    /// # Panics
832    ///
833    /// Panics if `window()` or `document()` is unavailable on the current platform.
834    pub fn inject_css<S>(css_text: S)
835    where
836        S: AsRef<str>,
837    {
838        let css_text: &str = css_text.as_ref();
839        Self::append_css(css_text);
840    }
841}
842
843/// Displays the CSS class name.
844///
845/// This enables `format!("{css}")` to produce the class name string,
846/// which is required for reactive `if` conditions in `class:` attributes.
847impl Display for Css {
848    /// Formats the CSS class as its name string.
849    ///
850    /// # Arguments
851    ///
852    /// - `&mut Formatter<'_>` - The formatter.
853    ///
854    /// # Returns
855    ///
856    /// - `fmt::Result` - The formatting result.
857    fn fmt(&self, formatter: &mut Formatter<'_>) -> fmt::Result {
858        write!(formatter, "{}", self.get_name())
859    }
860}