leptos_meta/
title.rs

1use crate::{use_head, MetaContext, ServerMetaContext};
2use leptos::{
3    attr::{any_attribute::AnyAttribute, Attribute},
4    component,
5    oco::Oco,
6    prelude::{ArcTrigger, Notify, Track},
7    reactive::{effect::RenderEffect, owner::use_context},
8    tachys::{
9        dom::document,
10        hydration::Cursor,
11        view::{
12            add_attr::AddAnyAttr, Mountable, Position, PositionState, Render,
13            RenderHtml,
14        },
15    },
16    text_prop::TextProp,
17    IntoView,
18};
19use or_poisoned::OrPoisoned;
20use std::sync::{
21    atomic::{AtomicU32, Ordering},
22    Arc, Mutex, RwLock,
23};
24
25/// Contains the current state of the document's `<title>`.
26#[derive(Clone, Default)]
27pub struct TitleContext {
28    id: Arc<AtomicU32>,
29    formatter_stack: Arc<RwLock<Vec<(TitleId, Formatter)>>>,
30    text_stack: Arc<RwLock<Vec<(TitleId, TextProp)>>>,
31    revalidate: ArcTrigger,
32    #[allow(clippy::type_complexity)]
33    effect: Arc<Mutex<Option<RenderEffect<Option<Oco<'static, str>>>>>>,
34}
35
36impl core::fmt::Debug for TitleContext {
37    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
38        f.debug_tuple("TitleContext").finish()
39    }
40}
41
42type TitleId = u32;
43
44impl TitleContext {
45    fn next_id(&self) -> TitleId {
46        self.id.fetch_add(1, Ordering::Relaxed)
47    }
48
49    fn invalidate(&self) {
50        self.revalidate.notify();
51    }
52
53    fn spawn_effect(&self) {
54        let this = self.clone();
55        let revalidate = self.revalidate.clone();
56
57        let mut effect_lock = self.effect.lock().or_poisoned();
58        if effect_lock.is_none() {
59            *effect_lock = Some(RenderEffect::new({
60                move |_| {
61                    revalidate.track();
62                    let text = this.as_string();
63                    document().set_title(text.as_deref().unwrap_or_default());
64                    text
65                }
66            }));
67        }
68    }
69
70    fn push_text_and_formatter(
71        &self,
72        id: TitleId,
73        text: Option<TextProp>,
74        formatter: Option<Formatter>,
75    ) {
76        if let Some(text) = text {
77            self.text_stack.write().or_poisoned().push((id, text));
78        }
79        if let Some(formatter) = formatter {
80            self.formatter_stack
81                .write()
82                .or_poisoned()
83                .push((id, formatter));
84        }
85        self.invalidate();
86    }
87
88    fn update_text_and_formatter(
89        &self,
90        id: TitleId,
91        text: Option<TextProp>,
92        formatter: Option<Formatter>,
93    ) {
94        let mut text_stack = self.text_stack.write().or_poisoned();
95        let mut formatter_stack = self.formatter_stack.write().or_poisoned();
96        let text_pos =
97            text_stack.iter().position(|(item_id, _)| *item_id == id);
98        let formatter_pos = formatter_stack
99            .iter()
100            .position(|(item_id, _)| *item_id == id);
101
102        match (text_pos, text) {
103            (None, None) => {}
104            (Some(old), Some(new)) => {
105                text_stack[old].1 = new;
106                self.invalidate();
107            }
108            (Some(old), None) => {
109                text_stack.remove(old);
110                self.invalidate();
111            }
112            (None, Some(new)) => {
113                text_stack.push((id, new));
114                self.invalidate();
115            }
116        }
117        match (formatter_pos, formatter) {
118            (None, None) => {}
119            (Some(old), Some(new)) => {
120                formatter_stack[old].1 = new;
121                self.invalidate();
122            }
123            (Some(old), None) => {
124                formatter_stack.remove(old);
125                self.invalidate();
126            }
127            (None, Some(new)) => {
128                formatter_stack.push((id, new));
129                self.invalidate();
130            }
131        }
132    }
133
134    fn remove_id(&self, id: TitleId) -> (Option<TextProp>, Option<Formatter>) {
135        let mut text_stack = self.text_stack.write().or_poisoned();
136        let text = text_stack
137            .iter()
138            .position(|(item_id, _)| *item_id == id)
139            .map(|pos| text_stack.remove(pos).1);
140
141        let mut formatter_stack = self.formatter_stack.write().or_poisoned();
142        let formatter = formatter_stack
143            .iter()
144            .position(|(item_id, _)| *item_id == id)
145            .map(|pos| formatter_stack.remove(pos).1);
146
147        self.invalidate();
148
149        (text, formatter)
150    }
151
152    /// Converts the title into a string that can be used as the text content of a `<title>` tag.
153    pub fn as_string(&self) -> Option<Oco<'static, str>> {
154        let title = self
155            .text_stack
156            .read()
157            .or_poisoned()
158            .last()
159            .map(|n| n.1.get());
160
161        title.map(|title| {
162            if let Some(formatter) =
163                self.formatter_stack.read().or_poisoned().last()
164            {
165                (formatter.1 .0)(title.into_owned()).into()
166            } else {
167                title
168            }
169        })
170    }
171}
172
173/// A function that is applied to the text value before setting `document.title`.
174#[repr(transparent)]
175pub struct Formatter(Box<dyn Fn(String) -> String + Send + Sync>);
176
177impl<F> From<F> for Formatter
178where
179    F: Fn(String) -> String + Send + Sync + 'static,
180{
181    #[inline(always)]
182    fn from(f: F) -> Formatter {
183        Formatter(Box::new(f))
184    }
185}
186
187/// A component to set the document’s title by creating an [`HTMLTitleElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLTitleElement).
188///
189/// The `title` and `formatter` can be set independently of one another. For example, you can create a root-level
190/// `<Title formatter=.../>` that will wrap each of the text values of `<Title/>` components created lower in the tree.
191///
192/// ```
193/// use leptos::prelude::*;
194/// use leptos_meta::*;
195///
196/// #[component]
197/// fn MyApp() -> impl IntoView {
198///     provide_meta_context();
199///     let formatter = |text| format!("{text} — Leptos Online");
200///
201///     view! {
202///       <main>
203///         <Title formatter/>
204///         // ... routing logic here
205///       </main>
206///     }
207/// }
208///
209/// #[component]
210/// fn PageA() -> impl IntoView {
211///     view! {
212///       <main>
213///         <Title text="Page A"/> // sets title to "Page A — Leptos Online"
214///       </main>
215///     }
216/// }
217///
218/// #[component]
219/// fn PageB() -> impl IntoView {
220///     view! {
221///       <main>
222///         <Title text="Page B"/> // sets title to "Page B — Leptos Online"
223///       </main>
224///     }
225/// }
226/// ```
227#[component]
228pub fn Title(
229    /// A function that will be applied to any text value before it’s set as the title.
230    #[prop(optional, into)]
231    mut formatter: Option<Formatter>,
232    /// Sets the current `document.title`.
233    #[prop(optional, into)]
234    mut text: Option<TextProp>,
235) -> impl IntoView {
236    let meta = use_head();
237    let server_ctx = use_context::<ServerMetaContext>();
238    let id = meta.title.next_id();
239    if let Some(cx) = server_ctx {
240        // if we are server rendering, we will not actually use these values via RenderHtml
241        // instead, they'll be handled separately by the server integration
242        // so it's safe to take them out of the props here
243        cx.title
244            .push_text_and_formatter(id, text.take(), formatter.take());
245    };
246
247    TitleView {
248        id,
249        meta,
250        formatter,
251        text,
252    }
253}
254
255struct TitleView {
256    id: u32,
257    meta: MetaContext,
258    formatter: Option<Formatter>,
259    text: Option<TextProp>,
260}
261
262struct TitleViewState {
263    id: TitleId,
264    meta: MetaContext,
265    // these are only Some(_) after being unmounted, and hold these values until dropped or remounted
266    formatter: Option<Formatter>,
267    text: Option<TextProp>,
268}
269
270impl Drop for TitleViewState {
271    fn drop(&mut self) {
272        // when TitleViewState is dropped, it should remove its ID from the text and formatter stacks
273        // so that they no longer appear. it will also revalidate the whole title in case this one was active
274        self.meta.title.remove_id(self.id);
275    }
276}
277
278impl Render for TitleView {
279    type State = TitleViewState;
280
281    fn build(self) -> Self::State {
282        let TitleView {
283            id,
284            meta,
285            formatter,
286            text,
287        } = self;
288        meta.title.spawn_effect();
289        TitleViewState {
290            id,
291            meta,
292            text,
293            formatter,
294        }
295    }
296
297    fn rebuild(self, _state: &mut Self::State) {
298        self.meta.title.update_text_and_formatter(
299            self.id,
300            self.text,
301            self.formatter,
302        );
303    }
304}
305
306impl AddAnyAttr for TitleView {
307    type Output<SomeNewAttr: Attribute> = TitleView;
308
309    fn add_any_attr<NewAttr: Attribute>(
310        self,
311        _attr: NewAttr,
312    ) -> Self::Output<NewAttr>
313    where
314        Self::Output<NewAttr>: RenderHtml,
315    {
316        self
317    }
318}
319
320impl RenderHtml for TitleView {
321    type AsyncOutput = Self;
322    type Owned = Self;
323
324    const MIN_LENGTH: usize = 0;
325
326    fn dry_resolve(&mut self) {}
327
328    async fn resolve(self) -> Self::AsyncOutput {
329        self
330    }
331
332    fn to_html_with_buf(
333        self,
334        _buf: &mut String,
335        _position: &mut Position,
336        _escape: bool,
337        _mark_branches: bool,
338        _extra_attrs: Vec<AnyAttribute>,
339    ) {
340        // meta tags are rendered into the buffer stored into the context
341        // the value has already been taken out, when we're on the server
342    }
343
344    fn hydrate<const FROM_SERVER: bool>(
345        self,
346        _cursor: &Cursor,
347        _position: &PositionState,
348    ) -> Self::State {
349        let TitleView {
350            id,
351            meta,
352            formatter,
353            text,
354        } = self;
355        meta.title.spawn_effect();
356        // these need to be pushed here, rather than on mount, because mount() is not called when hydrating
357        meta.title.push_text_and_formatter(id, text, formatter);
358        TitleViewState {
359            id,
360            meta,
361            text: None,
362            formatter: None,
363        }
364    }
365
366    fn into_owned(self) -> Self::Owned {
367        self
368    }
369}
370
371impl Mountable for TitleViewState {
372    fn unmount(&mut self) {
373        let (text, formatter) = self.meta.title.remove_id(self.id);
374        if text.is_some() {
375            self.text = text;
376        }
377        if formatter.is_some() {
378            self.formatter = formatter;
379        }
380    }
381
382    fn mount(
383        &mut self,
384        _parent: &leptos::tachys::renderer::types::Element,
385        _marker: Option<&leptos::tachys::renderer::types::Node>,
386    ) {
387        // TitleView::el() guarantees that there is a <title> in the <head>
388        // so there is no element to be mounted
389        //
390        // "mounting" in this case means that we actually want this title to be in active use
391        // as a result, we will push it into the title stack and revalidate
392        self.meta.title.push_text_and_formatter(
393            self.id,
394            self.text.take(),
395            self.formatter.take(),
396        );
397    }
398
399    fn insert_before_this(&self, _child: &mut dyn Mountable) -> bool {
400        false
401    }
402
403    fn elements(&self) -> Vec<leptos::tachys::renderer::types::Element> {
404        vec![]
405    }
406}