leptos_posthoc/
lib.rs

1#![cfg_attr(feature = "ssr", allow(unused_variables))]
2#![cfg_attr(feature = "ssr", allow(unused_mut))]
3#![cfg_attr(feature = "ssr", allow(unused_imports))]
4
5/*! Allows for "hydrating" an existent DOM with reactive leptos components,
6 * without the entire DOM having to be generated by leptos components.
7 *
8 * ## Why would you want that?
9 * 1. **CSR:** It allows for building scripts that others can just embed in their arbitrary HTML documents, that add 
10 *    `<insert your favourite fancy feature here>`. For an example, see the `examples/csr` directory: 
11 *    the `index.html` has a node `<script src='csr_example.js'></script>`, which "hydrates" nodes with the 
12 *    `data-replace-with-leptos`-attribute with leptos components that add a hover-popup (using 
13 *    [thaw](https://docs.rs/thaw)).
14 * 2. **SSR:** Occasionally, you might want to dynamically insert some HTML string into the DOM, for example one that 
15 *    gets generated from some data and returned by a server function. This HTML might contain certain nodes that 
16 *    we want to attach reactive functionality to. For an example, see the `examples/ssr` directory.
17 *
18 * ## CSR Example
19 * Say we want to replace all elements with the attribute `data-replace-with-leptos` with a leptos component 
20 * `MyReplacementComponent`, that simply wraps the original children in a `div` with a solid red border. This 
21 * component would roughly look like this:
22 * ```
23 * #[component]
24 * fn MyReplacementComponent(orig:OriginalNode) -> impl IntoView {
25 *    view! {
26 *       <div style="border: 1px solid red;">
27 *         <DomChildren orig />
28 *      </div>
29 *   }
30 * }
31 * ```
32 * This component takes an `orig:`[`OriginalNode`] that represents the, well, original [`Element`].
33 *
34 * So, where do we get `orig` from?
35 * - If we already have an `e:&`[`Element`], we can simply call `e.into()`.
36 * - More likely, we don't have an [`Element`] yet. Moreover, we probably want to iterate over the entire body 
37 *   *once* to find all nodes we want to make reactive, and we also need to set up a global reactive system for all 
38 *   our inserted components.
39 *
40 *   To do that, we call [`hydrate_body`] (requires the `csr` feature flag) with a function that takes the 
41 *   [`OriginalNode`] of the body and returns some leptos view; e.g.:
42 *
43 * ```
44 *  #[component]
45 *  fn MainBody(orig:OriginalNode) -> impl IntoView {
46 *     // set up some signals, provide context etc.
47 *     view!{
48 *       <DomChildren orig/>
49 *     }
50 *  }
51 *  #[wasm_bindgen(start)]
52 *   pub fn run() {
53 *       console_error_panic_hook::set_once();
54 *       hydrate_body(|orig| view!(<MainBody orig/>).into_any())
55 *   }
56 * ```
57 *
58 * This sets up the reactive system, but does not yet replace any elements further down in the DOM. To do that, 
59 * we provide a function that takes an `&`[`Element`] and optionally returns an 
60 * [`FnOnce`]`() -> impl `[`IntoView`]`+'static`, if the element should be changed. This function is then passed to 
61 * [`DomChildrenCont`], which will iterate over all children of the replaced element and replace them with the 
62 * provided function.
63 *
64 * Let's modify our `MainBody` to replace all elements with the attribute `data-replace-with-leptos` with a 
65 * `MyReplacementComponent`:
66 *
67 * ```
68 *  fn replace(e:&Element) -> Option<impl FnOnce() -> AnyView> {
69 *    e.get_attribute("data-replace-with-leptos").map(|_| {
70 *      let orig: OriginalNode = e.clone().into();
71 *      || view!(<MyReplacementComponent orig/>).into_any()
72 *    })
73 *  }
74 *
75 *  #[component]
76 *  fn MainBody(orig:OriginalNode) -> impl IntoView {
77 *     // set up some signals, provide context etc.
78 *     view!{
79 *       <DomChildrenCont orig cont=replace/>
80 *     }
81 *  }
82 *
83 * #[component]
84 * fn MyReplacementComponent(orig:OriginalNode) -> impl IntoView {
85 *    view! {
86 *       <div style="border: 1px solid red;">
87 *         <DomChildrenCont orig cont=replace/>
88 *      </div>
89 *   }
90 * }
91 * ```
92 *
93 * ...now, `replace` will get called on every element of the DOM, including those that were "moved around" in 
94 * earlier `MyReplacementComponent`s, respecting the proper reactive graph (regardin signal inheritance etc.).
95 *
96 * ### SSR Example
97 *
98 * In general, for SSR we can simply use the normal leptos components to generate the entire DOM. We control the 
99 * server, hence we control the DOM anyway.
100 *
101 * However, it might occasionally be the case that we want to dynamically *extend* the DOM at some point by 
102 * retrieving HTML from elsewhere, and then want to do a similar "hydration" iteration over the freshly inserted 
103 * nodes. This is what [`DomStringCont`] is for, and it does not require the `csr` feature:
104 *
105 * ```
106 *  #[component]
107 *  fn MyComponentThatGetsAStringFromSomewhere() -> impl IntoView {
108 *   // get some HTML string from somewhere
109 *   // e.g. some API call
110 *   let html = "<div data-replace-with-leptos>...</div>".to_string();
111 *   view! {
112 *     <DomStringCont html cont=replace/>
113 *   }
114 * }
115 * ```
116 *
117 * See the `examples/ssr` directory for a full example.
118*/
119
120mod dom;
121mod node;
122
123pub use node::OriginalNode;
124
125#[cfg(any(feature = "csr", feature = "hydrate"))]
126pub use dom::hydrate_node;
127
128use leptos::{html::Span, math::Mrow, prelude::*, web_sys::Element};
129
130/// A component that calls `cont` on `orig` and all its children,
131/// potentially "hydrating" them further, and reinserts the original
132/// element into the DOM.
133/// 
134/// If ``skip_head`` is set to true, `cont` will not be called on the head element itself.
135#[allow(unused_variables)] 
136#[component]
137pub fn DomCont<
138    V: IntoView + 'static,
139    R: FnOnce() -> V,
140    F: Fn(&Element) -> Option<R> + 'static + Send,
141>(
142    orig: OriginalNode,
143    cont: F,
144    #[prop(optional)] skip_head: bool,
145    #[prop(optional, into)] class: MaybeProp<String>,
146    #[prop(optional, into)] style: MaybeProp<String>,
147) -> impl IntoView {
148    #[cfg(any(feature = "csr", feature = "hydrate"))]
149    {
150        let orig = orig
151            .add_any_attr(leptos::tachys::html::class::class(move || class.get()))
152            .add_any_attr(leptos::tachys::html::style::style(move || style.get()));
153        orig.as_view(move |e| {
154            if skip_head {
155                dom::hydrate_children(e.clone().into(), &cont);
156            } else {
157                dom::hydrate_node(e.clone().into(), &cont);
158            }
159        })
160    }
161}
162
163/// A component that inserts the  children of some [`OriginalNode`]
164/// and renders them into the DOM.
165#[allow(unused_variables)] 
166#[component]
167pub fn DomChildren(orig: OriginalNode) -> impl IntoView {
168    #[cfg(any(feature = "csr", feature = "hydrate"))]
169    {
170        orig.child_vec()
171            .into_iter()
172            .map(|c| match c {
173                leptos::either::Either::Left(c) => leptos::either::Either::Left(c.as_view(|_| ())),
174                leptos::either::Either::Right(c) => leptos::either::Either::Right(c),
175            })
176            .collect_view()
177    }
178}
179
180/// A component that inserts the  children of some [`OriginalNode`] like [`DomChildren`],
181/// and then hydrates them using `cont` like [`DomCont`].
182#[allow(unused_variables)] 
183#[component]
184pub fn DomChildrenCont<
185    V: IntoView + 'static,
186    R: FnOnce() -> V,
187    F: Fn(&Element) -> Option<R> + 'static + Send + Clone,
188>(
189    orig: OriginalNode,
190    cont: F,
191) -> impl IntoView {
192    #[cfg(any(feature = "csr", feature = "hydrate"))]
193    {
194        orig.child_vec()
195            .into_iter()
196            .map(|c| match c {
197                leptos::either::Either::Left(c) => leptos::either::Either::Left({
198                    if let Some(r) = cont(&c) {
199                        leptos::either::Either::Left(r())
200                    } else {
201                        let cont = cont.clone();
202                        leptos::either::Either::Right(
203                            c.as_view(move |e| dom::hydrate_children(e.clone().into(), &cont)),
204                        )
205                    }
206                }),
207                leptos::either::Either::Right(c) => leptos::either::Either::Right(c),
208            })
209            .collect_view()
210    }
211}
212
213/// A component that renders a string of valid HTML, and then hydrates the resulting DOM nodes 
214/// using `cont` like [`DomCont`].
215#[allow(unused_variables)] 
216#[component]
217pub fn DomStringCont<
218    V: IntoView + 'static,
219    R: FnOnce() -> V,
220    F: Fn(&Element) -> Option<R> + 'static,
221>(
222    html: String,
223    cont: F,
224    #[prop(optional)] on_load: Option<RwSignal<bool>>,
225    #[prop(optional, into)] class: MaybeProp<String>,
226    #[prop(optional, into)] style: MaybeProp<String>,
227) -> impl IntoView {
228    let rf = NodeRef::<Span>::new();
229    rf.on_load(move |e| {
230        #[cfg(any(feature = "csr", feature = "hydrate"))]
231        {
232            dom::hydrate_children(e.into(), &cont);
233        }
234        if let Some(on_load) = on_load {
235            on_load.set(true);
236        }
237    });
238    view!(<span node_ref=rf inner_html=html
239      class=move || class.get() style=move || style.get()
240    />)
241}
242
243/// Like [`DomStringCont`], but using `<mrow>` instead of `<span>` initially, in case we are
244/// in MathML (otherwise, there's a danger the browser will move the resulting nodes outside of the
245/// `<math>` node!).
246#[allow(unused_variables)] 
247#[component]
248pub fn DomStringContMath<
249    V: IntoView + 'static,
250    R: FnOnce() -> V + 'static,
251    F: Fn(&Element) -> Option<R> + 'static + Send + Clone,
252>(
253    html: String,
254    cont: F,
255    #[prop(optional)] on_load: Option<RwSignal<bool>>,
256    #[prop(optional, into)] class: MaybeProp<String>,
257    #[prop(optional, into)] style: MaybeProp<String>,
258) -> impl IntoView {
259    let rf = NodeRef::<Mrow>::new();
260    let cnt = cont.clone();
261    rf.on_load(move |e| {
262        #[cfg(any(feature = "csr", feature = "hydrate"))]
263        {
264            dom::hydrate_children(e.into(), &cnt);
265        }
266        if let Some(on_load) = on_load {
267            on_load.set(true);
268        }
269    });
270    view!(<mrow node_ref=rf inner_html=html class=move || class.get() style=move || style.get()/>)
271}
272
273// need some check to not iterate over the entire body multiple times for some reason.
274// I'm not sure why this is necessary, but it seems to be.
275#[cfg(feature = "csr")]
276static DONE: std::sync::OnceLock<()> = std::sync::OnceLock::new();
277
278/// Hydrates the entire DOM with leptos components, starting at the body.
279///
280/// `v` is a function that takes the [`OriginalChildren`] of the `<body>` (likely reinserting them somewhere) and returns some 
281/// leptos view replacing the original children(!) of the body.
282#[cfg(feature = "csr")]
283pub fn hydrate_body<N: IntoView>(v: impl FnOnce(OriginalNode) -> N + 'static) {
284    // make sure this only ever happens once.
285    if DONE.get().is_some() {
286        return;
287    }
288    DONE.get_or_init(|| ());
289    let document = leptos::tachys::dom::document();
290    // We check that the DOM has been fully loaded
291    let state = document.ready_state();
292    let go = move || {
293        let body = leptos::tachys::dom::body();
294        let nd = leptos::tachys::dom::document()
295            .create_element("div")
296            .expect("Error creating div");
297        while let Some(c) = body.child_nodes().get(0) {
298            nd.append_child(&c).expect("Error appending child");
299        }
300        mount_to_body(move || v(nd.into()));
301    };
302    if state == "complete" || state == "interactive" {
303        go();
304    } else {
305        use leptos::wasm_bindgen::JsCast;
306        let fun = std::rc::Rc::new(std::cell::Cell::new(Some(go)));
307        let closure = leptos::wasm_bindgen::closure::Closure::wrap(Box::new(
308            move |_: leptos::web_sys::Event| {
309                if let Some(f) = fun.take() {
310                    f()
311                }
312            },
313        ) as Box<dyn FnMut(_)>);
314        document
315            .add_event_listener_with_callback("DOMContentLoaded", closure.as_ref().unchecked_ref())
316            .unwrap();
317        closure.forget();
318    }
319}
320
321// ------------------------------------------------------------
322
323#[cfg(any(feature = "csr", feature = "hydrate"))]
324fn cleanup(node: leptos::web_sys::Node) {
325    let c = send_wrapper::SendWrapper::new(node);
326    Owner::on_cleanup(move || {
327        if let Some(p) = c.parent_element() {
328            let _ = p.remove_child(&c);
329        }
330    });
331}
332
333/*
334#[cfg(any(feature="csr",feature="hydrate"))]
335fn prettyprint(node:&web_sys::Node) -> String {
336  use leptos::wasm_bindgen::JsCast;
337  if let Some(e) = node.dyn_ref::<Element>() {
338    e.outer_html()
339  } else if let Some(t) = node.dyn_ref::<web_sys::Text>() {
340    t.data()
341  } else if let Some(c) = node.dyn_ref::<web_sys::Comment>() {
342    c.data()
343  } else {
344    node.to_string().as_string().expect("wut")
345  }
346}
347   */