Skip to main content

topcoat_runtime/
connection.rs

1use std::any::TypeId;
2
3use topcoat_core::context::{Cx, try_request_context};
4use topcoat_view::{HoistKey, hoist_once};
5
6/// Identifies a render requested through a browser connection.
7///
8/// Add this to the request context when rendering over a connection.
9/// Leave it out for HTTP renders.
10#[derive(Debug, Default, Clone, Copy)]
11pub(crate) struct ConnectedRender;
12
13/// Returns whether this render runs over a browser connection and marks
14/// the content as needing one.
15///
16/// During an HTTP render, this returns `false`. Once the response
17/// finishes, the browser opens a connection and renders the enclosing page
18/// or shard again. That render returns `true`.
19///
20/// The connection request belongs to the enclosing page or shard. Layouts
21/// and components belong to the page that renders them. A shard inside
22/// content that already has a connection renders through it instead of
23/// opening its own.
24///
25/// Calling this requests a connection even if you only use the result to
26/// choose some text. Use [`connected_untracked`] to check without requesting
27/// one. Both functions describe this render, regardless of connections
28/// elsewhere in the document.
29///
30/// # Panics
31///
32/// Panics when called outside a rendering scope. Spawned tasks need their
33/// own rendering scope.
34#[must_use]
35#[track_caller]
36pub fn connected(cx: &Cx) -> bool {
37    hoist_once(HoistKey::new(TypeId::of::<ConnectedRender>()), |parts| {
38        parts.push_comment(|comment| {
39            comment.push_promoted_str_unescaped(&"::topcoat::connect");
40        });
41    });
42    connected_untracked(cx)
43}
44
45/// Returns whether this render runs over a browser connection without
46/// asking the browser to open one.
47///
48/// Use this to change what a component displays based on the connection.
49/// It works outside rendering scopes too and adds nothing to the output.
50#[must_use]
51pub fn connected_untracked(cx: &Cx) -> bool {
52    try_request_context::<ConnectedRender>(cx).is_some()
53}
54
55#[cfg(test)]
56mod tests {
57    use std::{
58        panic::{AssertUnwindSafe, catch_unwind},
59        pin::pin,
60        task::{Context, Poll, Waker},
61    };
62
63    use topcoat::view::{HoistView, ViewExt, internal::ThenView, view};
64
65    use super::*;
66
67    const MARKER: &str = "<!--::topcoat::connect-->";
68
69    /// Polls a future until it completes, without waiting between polls.
70    fn block_on<F: Future>(future: F) -> F::Output {
71        let mut future = pin!(future);
72        let mut cx = Context::from_waker(Waker::noop());
73        loop {
74            if let Poll::Ready(output) = future.as_mut().poll(&mut cx) {
75                return output;
76            }
77        }
78    }
79
80    /// Renders the result of `check` using the supplied context.
81    fn render(cx: Cx, check: impl Fn(&Cx) -> bool + Send + 'static) -> String {
82        let view = HoistView::new(ThenView::new(async move {
83            let cx = &cx;
84            let result = check(cx);
85            Ok(view! { cx => <p>(result)</p> })
86        }));
87        block_on(view.single()).unwrap().render(&Cx::default())
88    }
89
90    #[test]
91    fn an_http_render_is_not_connected_and_requests_a_connection() {
92        let html = render(Cx::default(), connected);
93        assert!(html.starts_with(MARKER), "{html}");
94        assert!(html.ends_with("<p>false</p>"), "{html}");
95    }
96
97    #[test]
98    fn a_connected_render_is_connected_and_keeps_requesting_a_connection() {
99        let html = render(Cx::default().with(ConnectedRender), connected);
100        assert!(html.starts_with(MARKER), "{html}");
101        assert!(html.ends_with("<p>true</p>"), "{html}");
102    }
103
104    #[test]
105    fn repeated_checks_render_one_marker() {
106        let html = render(Cx::default(), |cx| {
107            let _ = connected(cx);
108            let _ = connected(cx);
109            connected(cx)
110        });
111        assert_eq!(html.matches(MARKER).count(), 1, "{html}");
112    }
113
114    #[test]
115    fn the_untracked_check_renders_no_marker() {
116        let html = render(Cx::default(), connected_untracked);
117        assert!(!html.contains("::topcoat::connect"), "{html}");
118        assert!(html.ends_with("<p>false</p>"), "{html}");
119
120        let html = render(Cx::default().with(ConnectedRender), connected_untracked);
121        assert!(!html.contains("::topcoat::connect"), "{html}");
122        assert!(html.ends_with("<p>true</p>"), "{html}");
123    }
124
125    #[test]
126    fn a_cloned_context_shares_the_render_mode() {
127        let cx = Cx::default().with(ConnectedRender);
128        assert!(connected_untracked(&cx.keyed(1)));
129        assert!(connected_untracked(&cx.with(())));
130    }
131
132    #[test]
133    fn the_tracked_check_outside_a_body_panics() {
134        let cx = Cx::default();
135        let panic = catch_unwind(AssertUnwindSafe(|| connected(&cx))).unwrap_err();
136        let message = panic.downcast::<&str>().expect("panics with a message");
137        assert!(message.contains("no view is collecting hoisted parts"));
138    }
139
140    #[test]
141    fn the_untracked_check_works_outside_a_body() {
142        assert!(!connected_untracked(&Cx::default()));
143        assert!(connected_untracked(&Cx::default().with(ConnectedRender)));
144    }
145}