leptos_meta/
title.rs

1use crate::{use_head, MetaContext, ServerMetaContext};
2use leptos::{
3    attr::Attribute,
4    component,
5    oco::Oco,
6    reactive::{
7        effect::RenderEffect,
8        owner::{use_context, Owner},
9    },
10    tachys::{
11        dom::document,
12        hydration::Cursor,
13        view::{
14            add_attr::AddAnyAttr, Mountable, Position, PositionState, Render,
15            RenderHtml,
16        },
17    },
18    text_prop::TextProp,
19    IntoView,
20};
21use or_poisoned::OrPoisoned;
22use send_wrapper::SendWrapper;
23use std::sync::{Arc, RwLock};
24use wasm_bindgen::{JsCast, UnwrapThrowExt};
25use web_sys::HtmlTitleElement;
26
27/// Contains the current state of the document's `<title>`.
28#[derive(Clone, Default)]
29pub struct TitleContext {
30    el: Arc<RwLock<Option<SendWrapper<HtmlTitleElement>>>>,
31    formatter: Arc<RwLock<Option<Formatter>>>,
32    text: Arc<RwLock<Option<TextProp>>>,
33}
34
35impl TitleContext {
36    /// Converts the title into a string that can be used as the text content of a `<title>` tag.
37    pub fn as_string(&self) -> Option<Oco<'static, str>> {
38        let title = self.text.read().or_poisoned().as_ref().map(TextProp::get);
39        title.map(|title| {
40            if let Some(formatter) = &*self.formatter.read().or_poisoned() {
41                (formatter.0)(title.into_owned()).into()
42            } else {
43                title
44            }
45        })
46    }
47}
48
49impl core::fmt::Debug for TitleContext {
50    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
51        f.debug_tuple("TitleContext").finish()
52    }
53}
54
55/// A function that is applied to the text value before setting `document.title`.
56#[repr(transparent)]
57pub struct Formatter(Box<dyn Fn(String) -> String + Send + Sync>);
58
59impl<F> From<F> for Formatter
60where
61    F: Fn(String) -> String + Send + Sync + 'static,
62{
63    #[inline(always)]
64    fn from(f: F) -> Formatter {
65        Formatter(Box::new(f))
66    }
67}
68
69/// A component to set the document’s title by creating an [`HTMLTitleElement`](https://developer.mozilla.org/en-US/docs/Web/API/HTMLTitleElement).
70///
71/// The `title` and `formatter` can be set independently of one another. For example, you can create a root-level
72/// `<Title formatter=.../>` that will wrap each of the text values of `<Title/>` components created lower in the tree.
73///
74/// ```
75/// use leptos::prelude::*;
76/// use leptos_meta::*;
77///
78/// #[component]
79/// fn MyApp() -> impl IntoView {
80///     provide_meta_context();
81///     let formatter = |text| format!("{text} — Leptos Online");
82///
83///     view! {
84///       <main>
85///         <Title formatter/>
86///         // ... routing logic here
87///       </main>
88///     }
89/// }
90///
91/// #[component]
92/// fn PageA() -> impl IntoView {
93///     view! {
94///       <main>
95///         <Title text="Page A"/> // sets title to "Page A — Leptos Online"
96///       </main>
97///     }
98/// }
99///
100/// #[component]
101/// fn PageB() -> impl IntoView {
102///     view! {
103///       <main>
104///         <Title text="Page B"/> // sets title to "Page B — Leptos Online"
105///       </main>
106///     }
107/// }
108/// ```
109#[component]
110pub fn Title(
111    /// A function that will be applied to any text value before it’s set as the title.
112    #[prop(optional, into)]
113    mut formatter: Option<Formatter>,
114    /// Sets the current `document.title`.
115    #[prop(optional, into)]
116    mut text: Option<TextProp>,
117) -> impl IntoView {
118    let meta = use_head();
119    let server_ctx = use_context::<ServerMetaContext>();
120    if let Some(cx) = server_ctx {
121        // if we are server rendering, we will not actually use these values via RenderHtml
122        // instead, they'll be handled separately by the server integration
123        // so it's safe to take them out of the props here
124        if let Some(formatter) = formatter.take() {
125            *cx.title.formatter.write().or_poisoned() = Some(formatter);
126        }
127        if let Some(text) = text.take() {
128            *cx.title.text.write().or_poisoned() = Some(text);
129        }
130    };
131
132    TitleView {
133        meta,
134        formatter,
135        text,
136    }
137}
138
139struct TitleView {
140    meta: MetaContext,
141    formatter: Option<Formatter>,
142    text: Option<TextProp>,
143}
144
145impl TitleView {
146    fn el(&self) -> HtmlTitleElement {
147        let mut el_ref = self.meta.title.el.write().or_poisoned();
148        let el = if let Some(el) = &*el_ref {
149            el.clone()
150        } else {
151            match document().query_selector("title") {
152                Ok(Some(title)) => SendWrapper::new(title.unchecked_into()),
153                _ => {
154                    let el_ref = self.meta.title.el.clone();
155                    let el = SendWrapper::new(
156                        document()
157                            .create_element("title")
158                            .unwrap_throw()
159                            .unchecked_into::<HtmlTitleElement>(),
160                    );
161                    let head =
162                        SendWrapper::new(document().head().unwrap_throw());
163                    head.append_child(el.unchecked_ref()).unwrap_throw();
164
165                    Owner::on_cleanup({
166                        let el = el.clone();
167                        move || {
168                            _ = head.remove_child(&el);
169                            *el_ref.write().or_poisoned() = None;
170                        }
171                    });
172
173                    el
174                }
175            }
176        };
177        *el_ref = Some(el.clone());
178
179        el.take()
180    }
181}
182
183struct TitleViewState {
184    // effect is stored in the view state to keep it alive until rebuild
185    #[allow(dead_code)]
186    effect: RenderEffect<Oco<'static, str>>,
187}
188
189impl Render for TitleView {
190    type State = TitleViewState;
191
192    fn build(mut self) -> Self::State {
193        let el = self.el();
194        let meta = self.meta;
195        if let Some(formatter) = self.formatter.take() {
196            *meta.title.formatter.write().or_poisoned() = Some(formatter);
197        }
198        if let Some(text) = self.text.take() {
199            *meta.title.text.write().or_poisoned() = Some(text);
200        }
201        let effect = RenderEffect::new({
202            let el = el.clone();
203            move |prev| {
204                let text = meta.title.as_string().unwrap_or_default();
205
206                if prev.as_ref() != Some(&text) {
207                    el.set_text_content(Some(&text));
208                }
209
210                text
211            }
212        });
213        TitleViewState { effect }
214    }
215
216    fn rebuild(self, state: &mut Self::State) {
217        *state = self.build();
218    }
219}
220
221impl AddAnyAttr for TitleView {
222    type Output<SomeNewAttr: Attribute> = TitleView;
223
224    fn add_any_attr<NewAttr: Attribute>(
225        self,
226        _attr: NewAttr,
227    ) -> Self::Output<NewAttr>
228    where
229        Self::Output<NewAttr>: RenderHtml,
230    {
231        self
232    }
233}
234
235impl RenderHtml for TitleView {
236    type AsyncOutput = Self;
237
238    const MIN_LENGTH: usize = 0;
239
240    fn dry_resolve(&mut self) {}
241
242    async fn resolve(self) -> Self::AsyncOutput {
243        self
244    }
245
246    fn to_html_with_buf(
247        self,
248        _buf: &mut String,
249        _position: &mut Position,
250        _escape: bool,
251        _mark_branches: bool,
252    ) {
253        // meta tags are rendered into the buffer stored into the context
254        // the value has already been taken out, when we're on the server
255    }
256
257    fn hydrate<const FROM_SERVER: bool>(
258        mut self,
259        _cursor: &Cursor,
260        _position: &PositionState,
261    ) -> Self::State {
262        let el = self.el();
263        let meta = self.meta;
264        if let Some(formatter) = self.formatter.take() {
265            *meta.title.formatter.write().or_poisoned() = Some(formatter);
266        }
267        if let Some(text) = self.text.take() {
268            *meta.title.text.write().or_poisoned() = Some(text);
269        }
270        let effect = RenderEffect::new({
271            let el = el.clone();
272            move |prev| {
273                let text = meta.title.as_string().unwrap_or_default();
274
275                // don't reset the title on initial hydration
276                if prev.is_some() && prev.as_ref() != Some(&text) {
277                    el.set_text_content(Some(&text));
278                }
279
280                text
281            }
282        });
283        TitleViewState { effect }
284    }
285}
286
287impl Mountable for TitleViewState {
288    fn unmount(&mut self) {}
289
290    fn mount(
291        &mut self,
292        _parent: &leptos::tachys::renderer::types::Element,
293        _marker: Option<&leptos::tachys::renderer::types::Node>,
294    ) {
295        // <title> doesn't need to be mounted
296        // TitleView::el() guarantees that there is a <title> in the <head>
297    }
298
299    fn insert_before_this(&self, _child: &mut dyn Mountable) -> bool {
300        false
301    }
302}