Skip to main content

topcoat_runtime/
link.rs

1// Show router links as plain text when the router feature is disabled.
2#![cfg_attr(not(feature = "router"), allow(rustdoc::broken_intra_doc_links))]
3
4use topcoat_core::{
5    context::{Cx, try_app_context, try_request_context},
6    error::Result,
7};
8use topcoat_view::{AttributeValueViewParts, Attributes, Child, View};
9use topcoat_view_macro::{component, view};
10
11/// Enables runtime navigation on an anchor and sets its prefetch mode.
12const LINK_ATTRIBUTE: &str = "data-topcoat-link";
13
14/// Controls when the browser loads a linked page before the user follows it.
15///
16/// Loading a page early, or prefetching, can make navigation faster. The
17/// runtime may skip it, for example if the browser is set to save data.
18/// Prefetching renders the page on the server even if the user never opens
19/// it, so rendering a page must be safe in that case.
20///
21/// Set the `prefetch` argument on [`link`] to choose when to load that page.
22/// Otherwise, the link uses the default returned by [`prefetch_mode`].
23#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
24pub enum PrefetchMode {
25    /// Loads the page only after the user follows the link.
26    Never,
27    /// Loads the page after the user hovers over the link briefly or as
28    /// soon as the link gets focus. This is the default.
29    #[default]
30    Intent,
31    /// Loads the page when the link comes into view.
32    Viewport,
33}
34
35impl PrefetchMode {
36    /// Returns the mode's name for use in the HTML attribute.
37    const fn as_str(self) -> &'static str {
38        match self {
39            Self::Never => "never",
40            Self::Intent => "intent",
41            Self::Viewport => "viewport",
42        }
43    }
44}
45
46/// Returns the default prefetch mode for links in this context.
47///
48/// Checks these sources in order and uses the first value it finds:
49///
50/// 1. A `PrefetchMode` in the request context.
51/// 2. A `PrefetchMode` in the app context.
52/// 3. [`PrefetchMode::Intent`].
53///
54/// Set the default for the app with
55/// [`RouterBuilderRuntimeExt::prefetch`](crate::RouterBuilderRuntimeExt::prefetch),
56/// or use [`Cx::with`] to set a `PrefetchMode` for links rendered in a
57/// particular scope.
58#[must_use]
59pub fn prefetch_mode(cx: &Cx) -> PrefetchMode {
60    try_request_context::<PrefetchMode>(cx)
61        .or_else(|| try_app_context::<PrefetchMode>(cx))
62        .copied()
63        .unwrap_or_default()
64}
65
66/// Creates attributes that let an `<a>` element use runtime navigation.
67///
68/// Spread the returned attributes into your own `<a>` element to give it
69/// the same behavior as [`link`]. They set its `href` and enable runtime
70/// navigation with the chosen `prefetch` mode. Use an
71/// [`href!`](https://docs.rs/topcoat/latest/topcoat/router/macro.href.html)
72/// value for a page in your app. Call [`prefetch_mode`] to get the default
73/// mode for the current context.
74///
75/// Without JavaScript, the element still works as a regular link. The
76/// browser also handles clicks with modifier keys, links to other windows
77/// or frames, downloads, and links to other sites as usual.
78#[must_use]
79pub fn link_attrs(
80    cx: &Cx,
81    href: impl AttributeValueViewParts,
82    prefetch: PrefetchMode,
83) -> Attributes {
84    let mut attrs = Attributes::with_capacity(2);
85    attrs.insert(cx, "href", href);
86    attrs.insert(cx, LINK_ATTRIBUTE, prefetch.as_str());
87    attrs
88}
89
90#[doc = include_str!("../docs/link.md")]
91#[component]
92pub async fn link<H>(
93    cx: &Cx,
94    /// The URL to open. Use `href!` for pages in your app.
95    href: H,
96    /// When to load the page before the user follows the link. Uses
97    /// [`prefetch_mode`] if omitted.
98    #[into]
99    #[default]
100    prefetch: Option<PrefetchMode>,
101    /// Additional HTML attributes to put on the link.
102    #[default]
103    mut attrs: Attributes,
104    /// The text or other content inside the link.
105    #[default]
106    child: Child<'_>,
107) -> Result<impl View>
108where
109    H: AttributeValueViewParts + Send,
110{
111    let prefetch = prefetch.unwrap_or_else(|| prefetch_mode(cx));
112    attrs.extend(link_attrs(cx, href, prefetch));
113    Ok(view! { <a (attrs)>(child)</a> })
114}
115
116#[cfg(test)]
117mod tests {
118    use std::{
119        pin::pin,
120        task::{Context, Poll, Waker},
121    };
122
123    use topcoat::view::{ViewExt, attributes, view};
124    use topcoat_core::context::CxTestBuilder;
125
126    use super::*;
127
128    /// Runs a future to completion by polling it repeatedly without a pause.
129    fn block_on<F: Future>(future: F) -> F::Output {
130        let mut future = pin!(future);
131        let mut cx = Context::from_waker(Waker::noop());
132        loop {
133            if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
134                return output;
135            }
136        }
137    }
138
139    fn render(view: impl View) -> String {
140        block_on(view.single()).unwrap().render(&Cx::default())
141    }
142
143    #[test]
144    fn link_attrs_mark_the_anchor_with_its_destination_and_mode() {
145        let cx = &Cx::default();
146        let attrs = link_attrs(cx, "/products", PrefetchMode::Viewport);
147        let html = render(view! { cx => <a (attrs)>"Products"</a> });
148
149        assert!(html.contains(r#"href="/products""#), "{html}");
150        assert!(html.contains(r#"data-topcoat-link="viewport""#), "{html}");
151    }
152
153    #[test]
154    fn every_mode_renders_differently() {
155        let cx = &Cx::default();
156        let mut seen = Vec::new();
157        for prefetch in [
158            PrefetchMode::Never,
159            PrefetchMode::Intent,
160            PrefetchMode::Viewport,
161        ] {
162            let attrs = link_attrs(cx, "/", prefetch);
163            let html = render(view! { cx => <a (attrs)></a> });
164            assert!(!seen.contains(&html), "{html}");
165            seen.push(html);
166        }
167    }
168
169    #[test]
170    fn link_renders_an_anchor_with_forwarded_attributes() {
171        let cx = &Cx::default();
172        let attrs = attributes! { cx => class="nav" aria-current="page" };
173        let html = render(view! { cx => link(href: "/account", attrs: attrs, "Account") });
174
175        assert!(html.starts_with("<a "), "{html}");
176        assert!(html.contains(r#"href="/account""#), "{html}");
177        assert!(html.contains(r#"class="nav""#), "{html}");
178        assert!(html.contains(r#"aria-current="page""#), "{html}");
179        assert!(html.contains(">Account</a>"), "{html}");
180        // The default mode loads the page on hover or focus.
181        assert!(html.contains(r#"data-topcoat-link="intent""#), "{html}");
182    }
183
184    #[test]
185    fn explicit_props_take_precedence_over_forwarded_attributes() {
186        let cx = &Cx::default();
187        let attrs = attributes! { cx => href="/elsewhere" data-topcoat-link="viewport" };
188        let html = render(view! {
189            cx =>
190            link(href: "/account", prefetch: PrefetchMode::Never, attrs: attrs)
191        });
192
193        assert!(html.contains(r#"href="/account""#), "{html}");
194        assert!(!html.contains("/elsewhere"), "{html}");
195        assert!(!html.contains("viewport"), "{html}");
196    }
197
198    #[test]
199    fn prefetch_mode_prefers_the_request_context_over_the_app_context() {
200        let app = CxTestBuilder::new()
201            .app_context(PrefetchMode::Viewport)
202            .build();
203        assert_eq!(prefetch_mode(&app), PrefetchMode::Viewport);
204
205        let scoped = app.with(PrefetchMode::Never);
206        assert_eq!(prefetch_mode(&scoped), PrefetchMode::Never);
207    }
208
209    #[test]
210    fn link_uses_the_context_mode_unless_given_one() {
211        let cx = &CxTestBuilder::new()
212            .app_context(PrefetchMode::Viewport)
213            .build();
214        let html = render(view! { cx => link(href: "/", "Home") });
215        assert!(html.contains(r#"data-topcoat-link="viewport""#), "{html}");
216
217        let explicit = render(view! {
218            cx =>
219            link(href: "/", prefetch: PrefetchMode::Never, "Home")
220        });
221        assert!(
222            explicit.contains(r#"data-topcoat-link="never""#),
223            "{explicit}"
224        );
225    }
226}