Browse Source

wip: add router

Jonathan Kelley 3 years ago
parent
commit
d298b62

+ 2 - 1
Cargo.toml

@@ -9,6 +9,7 @@ description = "Core functionality for Dioxus - a concurrent renderer-agnostic Vi
 
 [dependencies]
 dioxus-core = { path = "./packages/core", version = "0.1.3" }
+dioxus-router = { path = "./packages/router", optional = true }
 dioxus-core-macro = { path = "./packages/core-macro", optional = true }
 dioxus-html = { path = "./packages/html", optional = true }
 dioxus-web = { path = "./packages/web", optional = true }
@@ -24,10 +25,10 @@ core = ["macro", "hooks", "html"]
 macro = ["dioxus-core-macro"]
 hooks = ["dioxus-hooks"]
 html = ["dioxus-html"]
+router = ["dioxus-router"]
 
 # utilities
 atoms = []
-router = []
 
 # targets
 ssr = ["dioxus-ssr"]

+ 0 - 2
docs/guide/src/concepts/interactivity.md

@@ -173,8 +173,6 @@ In general, Dioxus should be plenty fast for most use cases. However, there are
 
 Don't worry - Dioxus is fast. But, if your app needs *extreme performance*, then take a look at the `Performance Tuning` in the `Advanced Guides` book.
 
-
-
 ## Moving On
 
 This overview was a lot of information - but it doesn't tell you everything!

+ 34 - 8
packages/core-macro/src/lib.rs

@@ -5,6 +5,7 @@ use syn::parse_macro_input;
 pub(crate) mod htm;
 pub(crate) mod ifmt;
 pub(crate) mod props;
+pub(crate) mod router;
 pub(crate) mod rsx;
 
 #[proc_macro]
@@ -82,12 +83,9 @@ pub fn derive_typed_builder(input: proc_macro::TokenStream) -> proc_macro::Token
 ///             }}
 ///            
 ///             // Matching
-///             // Matching will throw a Rust error about "no two closures are the same type"
-///             // To fix this, call "render" method or use the "in" syntax to produce VNodes.
-///             // There's nothing we can do about it, sorry :/ (unless you want *really* unhygenic macros)
 ///             {match true {
-///                 true => rsx!(cx, h1 {"Top text"}),
-///                 false => cx.render(rsx!( h1 {"Bottom text"}))
+///                 true => rsx!(h1 {"Top text"}),
+///                 false => rsx!(h1 {"Bottom text"})
 ///             }}
 ///
 ///             // Conditional rendering
@@ -95,11 +93,11 @@ pub fn derive_typed_builder(input: proc_macro::TokenStream) -> proc_macro::Token
 ///             // You can convert a bool condition to rsx! with .then and .or
 ///             {true.then(|| rsx!(div {}))}
 ///
-///             // True conditions need to be rendered (same reasons as matching)
+///             // True conditions
 ///             {if true {
-///                 rsx!(cx, h1 {"Top text"})
+///                 rsx!(h1 {"Top text"})
 ///             } else {
-///                 rsx!(cx, h1 {"Bottom text"})
+///                 rsx!(h1 {"Bottom text"})
 ///             }}
 ///
 ///             // returning "None" is a bit noisy... but rare in practice
@@ -186,3 +184,31 @@ pub fn rsx(s: TokenStream) -> TokenStream {
         Ok(s) => s.to_token_stream().into(),
     }
 }
+
+/// Derive macro used to mark an enum as Routable.
+///
+/// This macro can only be used on enums. Every varient of the macro needs to be marked
+/// with the `at` attribute to specify the URL of the route. It generates an implementation of
+///  `yew_router::Routable` trait and `const`s for the routes passed which are used with `Route`
+/// component.
+///
+/// # Example
+///
+/// ```
+/// # use yew_router::Routable;
+/// #[derive(Debug, Clone, Copy, PartialEq, Routable)]
+/// enum Routes {
+///     #[at("/")]
+///     Home,
+///     #[at("/secure")]
+///     Secure,
+///     #[at("/404")]
+///     NotFound,
+/// }
+/// ```
+#[proc_macro_derive(Routable, attributes(at, not_found))]
+pub fn routable_derive(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
+    use router::{routable_derive_impl, Routable};
+    let input = parse_macro_input!(input as Routable);
+    routable_derive_impl(input).into()
+}

+ 247 - 0
packages/core-macro/src/router.rs

@@ -0,0 +1,247 @@
+use proc_macro2::TokenStream;
+use quote::quote;
+use syn::parse::{Parse, ParseStream};
+use syn::punctuated::Punctuated;
+use syn::spanned::Spanned;
+use syn::{Data, DeriveInput, Fields, Ident, LitStr, Variant};
+
+const AT_ATTR_IDENT: &str = "at";
+const NOT_FOUND_ATTR_IDENT: &str = "not_found";
+
+pub struct Routable {
+    ident: Ident,
+    ats: Vec<LitStr>,
+    variants: Punctuated<Variant, syn::token::Comma>,
+    not_found_route: Option<Ident>,
+}
+
+impl Parse for Routable {
+    fn parse(input: ParseStream) -> syn::Result<Self> {
+        let DeriveInput { ident, data, .. } = input.parse()?;
+
+        let data = match data {
+            Data::Enum(data) => data,
+            Data::Struct(s) => {
+                return Err(syn::Error::new(
+                    s.struct_token.span(),
+                    "expected enum, found struct",
+                ))
+            }
+            Data::Union(u) => {
+                return Err(syn::Error::new(
+                    u.union_token.span(),
+                    "expected enum, found union",
+                ))
+            }
+        };
+
+        let (not_found_route, ats) = parse_variants_attributes(&data.variants)?;
+
+        Ok(Self {
+            ident,
+            variants: data.variants,
+            ats,
+            not_found_route,
+        })
+    }
+}
+
+fn parse_variants_attributes(
+    variants: &Punctuated<Variant, syn::token::Comma>,
+) -> syn::Result<(Option<Ident>, Vec<LitStr>)> {
+    let mut not_founds = vec![];
+    let mut ats: Vec<LitStr> = vec![];
+
+    let mut not_found_attrs = vec![];
+
+    for variant in variants.iter() {
+        if let Fields::Unnamed(ref field) = variant.fields {
+            return Err(syn::Error::new(
+                field.span(),
+                "only named fields are supported",
+            ));
+        }
+
+        let attrs = &variant.attrs;
+        let at_attrs = attrs
+            .iter()
+            .filter(|attr| attr.path.is_ident(AT_ATTR_IDENT))
+            .collect::<Vec<_>>();
+
+        let attr = match at_attrs.len() {
+            1 => *at_attrs.first().unwrap(),
+            0 => {
+                return Err(syn::Error::new(
+                    variant.span(),
+                    format!(
+                        "{} attribute must be present on every variant",
+                        AT_ATTR_IDENT
+                    ),
+                ))
+            }
+            _ => {
+                return Err(syn::Error::new_spanned(
+                    quote! { #(#at_attrs)* },
+                    format!("only one {} attribute must be present", AT_ATTR_IDENT),
+                ))
+            }
+        };
+
+        let lit = attr.parse_args::<LitStr>()?;
+        ats.push(lit);
+
+        for attr in attrs.iter() {
+            if attr.path.is_ident(NOT_FOUND_ATTR_IDENT) {
+                not_found_attrs.push(attr);
+                not_founds.push(variant.ident.clone())
+            }
+        }
+    }
+
+    if not_founds.len() > 1 {
+        return Err(syn::Error::new_spanned(
+            quote! { #(#not_found_attrs)* },
+            format!("there can only be one {}", NOT_FOUND_ATTR_IDENT),
+        ));
+    }
+
+    Ok((not_founds.into_iter().next(), ats))
+}
+
+impl Routable {
+    fn build_from_path(&self) -> TokenStream {
+        let from_path_matches = self.variants.iter().enumerate().map(|(i, variant)| {
+            let ident = &variant.ident;
+            let right = match &variant.fields {
+                Fields::Unit => quote! { Self::#ident },
+                Fields::Named(field) => {
+                    let fields = field.named.iter().map(|it| {
+                        //named fields have idents
+                        it.ident.as_ref().unwrap()
+                    });
+                    quote! { Self::#ident { #(#fields: params.get(stringify!(#fields))?.parse().ok()?,)* } }
+                }
+                Fields::Unnamed(_) => unreachable!(), // already checked
+            };
+
+            let left = self.ats.get(i).unwrap();
+            quote! {
+                #left => ::std::option::Option::Some(#right)
+            }
+        });
+
+        quote! {
+            fn from_path(path: &str, params: &::std::collections::HashMap<&str, &str>) -> ::std::option::Option<Self> {
+                match path {
+                    #(#from_path_matches),*,
+                    _ => ::std::option::Option::None,
+                }
+            }
+        }
+    }
+
+    fn build_to_path(&self) -> TokenStream {
+        let to_path_matches = self.variants.iter().enumerate().map(|(i, variant)| {
+            let ident = &variant.ident;
+            let mut right = self.ats.get(i).unwrap().value();
+
+            match &variant.fields {
+                Fields::Unit => quote! { Self::#ident => ::std::string::ToString::to_string(#right) },
+                Fields::Named(field) => {
+                    let fields = field
+                        .named
+                        .iter()
+                        .map(|it| it.ident.as_ref().unwrap())
+                        .collect::<Vec<_>>();
+
+                    for field in fields.iter() {
+                        // :param -> {param}
+                        // so we can pass it to `format!("...", param)`
+                        right = right.replace(&format!(":{}", field), &format!("{{{}}}", field))
+                    }
+
+                    quote! {
+                        Self::#ident { #(#fields),* } => ::std::format!(#right, #(#fields = #fields),*)
+                    }
+                }
+                Fields::Unnamed(_) => unreachable!(), // already checked
+            }
+        });
+
+        quote! {
+            fn to_path(&self) -> ::std::string::String {
+                match self {
+                    #(#to_path_matches),*,
+                }
+            }
+        }
+    }
+}
+
+pub fn routable_derive_impl(input: Routable) -> TokenStream {
+    let Routable {
+        ats,
+        not_found_route,
+        ident,
+        ..
+    } = &input;
+
+    let from_path = input.build_from_path();
+    let to_path = input.build_to_path();
+
+    let not_found_route = match not_found_route {
+        Some(route) => quote! { ::std::option::Option::Some(Self::#route) },
+        None => quote! { ::std::option::Option::None },
+    };
+
+    let cache_thread_local_ident = Ident::new(
+        &format!("__{}_ROUTER_CURRENT_ROUTE_CACHE", ident),
+        ident.span(),
+    );
+
+    quote! {
+        ::std::thread_local! {
+            #[doc(hidden)]
+            #[allow(non_upper_case_globals)]
+            static #cache_thread_local_ident: ::std::cell::RefCell<::std::option::Option<#ident>> = ::std::cell::RefCell::new(::std::option::Option::None);
+        }
+
+        #[automatically_derived]
+        impl ::dioxus::router::Routable for #ident {
+            #from_path
+            #to_path
+
+            fn routes() -> ::std::vec::Vec<&'static str> {
+                ::std::vec![#(#ats),*]
+            }
+
+            fn not_found_route() -> ::std::option::Option<Self> {
+                #not_found_route
+            }
+
+            fn current_route() -> ::std::option::Option<Self> {
+                #cache_thread_local_ident.with(|val| ::std::clone::Clone::clone(&*val.borrow()))
+            }
+
+            fn recognize(pathname: &str) -> ::std::option::Option<Self> {
+                ::std::thread_local! {
+                    static ROUTER: ::dioxus::router::__macro::Router = ::dioxus::router::__macro::build_router::<#ident>();
+                }
+                let route = ROUTER.with(|router| ::dioxus::router::__macro::recognize_with_router(router, pathname));
+                {
+                    let route = ::std::clone::Clone::clone(&route);
+                    #cache_thread_local_ident.with(move |val| {
+                        *val.borrow_mut() = route;
+                    });
+                }
+                route
+            }
+
+            fn cleanup() {
+                #cache_thread_local_ident.with(move |val| {
+                    *val.borrow_mut() = ::std::option::Option::None;
+                });
+            }
+        }
+    }
+}

+ 1 - 2
packages/core-macro/src/rsx/body.rs

@@ -49,9 +49,8 @@ impl ToTokens for CallBody {
         match &self.custom_context {
             // The `in cx` pattern allows directly rendering
             Some(ident) => out_tokens.append_all(quote! {
-                #ident.render(dioxus::prelude::LazyNodes::new(move |__cx: NodeFactory| -> VNode {
+                #ident.render(NodeFactory::annotate_lazy(move |__cx: NodeFactory| -> VNode {
                     use dioxus_elements::{GlobalAttributes, SvgAttributes};
-
                     #inner
                 }))
             }),

+ 2 - 2
packages/core/src/component.rs

@@ -15,7 +15,7 @@ use crate::innerlude::{Context, Element, VNode};
 /// ```rust
 /// struct State {}
 ///
-/// fn Example((cx, props): Component<State>) -> DomTree {
+/// fn Example((cx, props): Scope<State>) -> DomTree {
 ///     // ...
 /// }
 /// ```
@@ -26,7 +26,7 @@ use crate::innerlude::{Context, Element, VNode};
 ///     name: &'a str
 /// }
 ///
-/// fn Example<'a>((cx, props): Component<'a, State>) -> DomTree<'a> {
+/// fn Example<'a>((cx, props): Scope<'a, State>) -> DomTree<'a> {
 ///     // ...
 /// }
 /// ```

+ 4 - 5
packages/core/src/context.rs

@@ -132,12 +132,11 @@ impl<'src> Context<'src> {
         (self.scope.shared.submit_task)(task)
     }
 
-    /// This hook enables the ability to expose state to children further down the VirtualDOM Tree.
+    /// This method enables the ability to expose state to children further down the VirtualDOM Tree.
     ///
-    /// This is a hook, so it should not be called conditionally!
+    /// This is a "fundamental" operation and should only be called during initialization of a hook.
     ///
-    /// The init method is ran *only* on first use, otherwise it is ignored. However, it uses hooks (ie `use`)
-    /// so don't put it in a conditional.
+    /// For a hook that provides the same functionality, use `use_provide_state` and `use_consume_state` instead.
     ///
     /// When the component is dropped, so is the context. Be aware of this behavior when consuming
     /// the context via Rc/Weak.
@@ -148,7 +147,7 @@ impl<'src> Context<'src> {
     /// struct SharedState(&'static str);
     ///
     /// static App: FC<()> = |(cx, props)|{
-    ///     cx.provide_state(SharedState("world"));
+    ///     cx.use_hook(|_| cx.provide_state(SharedState("world")), |_| {}, |_| {});
     ///     rsx!(cx, Child {})
     /// }
     ///

+ 39 - 0
packages/router/Cargo.toml

@@ -0,0 +1,39 @@
+[package]
+name = "dioxus-router"
+version = "0.0.0"
+edition = "2018"
+
+# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
+
+[dependencies]
+dioxus-core = { path = "../core", version = "0.1.3", default-features = false }
+dioxus-core-macro = { path = "../core-macro", version = "0.1" }
+dioxus-html = { path = "../html", version = "0.0.0", default-features = false }
+
+web-sys = { version = "0.3", features = [
+    "Attr",
+    "Document",
+    "History",
+    "HtmlBaseElement",
+    "Event",
+    "NamedNodeMap",
+    "Url",
+    "UrlSearchParams",
+    "Window",
+], optional = true }
+
+serde = "1"
+
+serde_urlencoded = "0.7"
+
+wasm-bindgen = "0.2"
+js-sys = "0.3"
+gloo = "0.4"
+route-recognizer = "0.3.1"
+
+
+[features]
+default = ["web"]
+web = ["web-sys"]
+desktop = []
+mobile = []

+ 28 - 0
packages/router/examples/blog.rs

@@ -0,0 +1,28 @@
+use dioxus::prelude::*;
+
+fn main() {}
+
+enum Routes {
+    Home,
+    Blog,
+}
+
+static App: FC<()> = |(cx, props)| {
+    let route = use_router::<Routes>(cx);
+
+    let content = match route {
+        Routes::Home => rsx!(Home {}),
+        Routes::Blog => rsx!(Blog {}),
+    };
+
+    cx.render(rsx! {
+        {content}
+    })
+};
+
+fn use_router<P>(cx: Context) -> &P {
+    todo!()
+}
+
+static Home: FC<()> = |(cx, props)| todo!();
+static Blog: FC<()> = |(cx, props)| todo!();

+ 88 - 0
packages/router/examples/v2.rs

@@ -0,0 +1,88 @@
+use dioxus::prelude::*;
+use dioxus_router::{use_router, Link, Routable};
+
+fn main() {
+    dbg!(App as *const fn());
+}
+
+#[derive(PartialEq, Debug, Clone)]
+pub enum Route {
+    // #[at("/")]
+    Home,
+
+    // #[at("/:id")]
+    AllUsers { page: u32 },
+
+    // #[at("/:id")]
+    User { id: u32 },
+
+    // #[at("/:id")]
+    BlogList { page: u32 },
+
+    // #[at("/:id")]
+    BlogPost { post_id: u32 },
+
+    // #[at("/404")]
+    // #[not_found]
+    NotFound,
+}
+
+static App: FC<()> = |(cx, props)| {
+    let route = use_router(cx, Route::parse)?;
+
+    cx.render(rsx! {
+        nav {
+            Link { to: Route::Home, "Go home!" }
+            Link { to: Route::AllUsers { page: 0 }, "List all users" }
+            Link { to: Route::BlogList { page: 0 }, "Blog posts" }
+        }
+        {match route {
+            Route::Home => rsx!("Home"),
+            Route::AllUsers { page } => rsx!("All users - page {page}"),
+            Route::User { id } => rsx!("User - id: {id}"),
+            Route::BlogList { page } => rsx!("Blog posts - page {page}"),
+            Route::BlogPost { post_id } => rsx!("Blog post - post {post_id}"),
+            Route::NotFound => rsx!("Not found"),
+        }}
+        footer {}
+    })
+};
+
+impl Route {
+    // Generate the appropriate route from the "tail" end of the URL
+    fn parse(url: &str) -> Self {
+        use Route::*;
+        match url {
+            "/" => Home,
+            "/users" => AllUsers { page: 1 },
+            "/users/:page" => AllUsers { page: 1 },
+            "/users/:page/:id" => User { id: 1 },
+            "/blog" => BlogList { page: 1 },
+            "/blog/:page" => BlogList { page: 1 },
+            "/blog/:page/:id" => BlogPost { post_id: 1 },
+            _ => NotFound,
+        }
+    }
+}
+
+impl Routable for Route {
+    fn from_path(path: &str, params: &std::collections::HashMap<&str, &str>) -> Option<Self> {
+        todo!()
+    }
+
+    fn to_path(&self) -> String {
+        todo!()
+    }
+
+    fn routes() -> Vec<&'static str> {
+        todo!()
+    }
+
+    fn not_found_route() -> Option<Self> {
+        todo!()
+    }
+
+    fn recognize(pathname: &str) -> Option<Self> {
+        todo!()
+    }
+}

+ 140 - 0
packages/router/src/lib.rs

@@ -0,0 +1,140 @@
+mod utils;
+
+use std::{cell::RefCell, collections::HashMap, rc::Rc};
+
+use dioxus_core as dioxus;
+use dioxus_core::prelude::*;
+use dioxus_core_macro::{format_args_f, rsx, Props};
+use dioxus_html as dioxus_elements;
+use wasm_bindgen::JsValue;
+use web_sys::Event;
+
+use crate::utils::fetch_base_url;
+
+pub struct RouterService<R: Routable> {
+    history: RefCell<Vec<R>>,
+    base_ur: RefCell<Option<String>>,
+}
+
+impl<R: Routable> RouterService<R> {
+    fn push_route(&self, r: R) {
+        self.history.borrow_mut().push(r);
+    }
+
+    fn get_current_route(&self) -> &str {
+        todo!()
+    }
+
+    fn update_route_impl(&self, url: String, push: bool) {
+        let history = web_sys::window().unwrap().history().expect("no history");
+        let base = self.base_ur.borrow();
+        let path = match base.as_ref() {
+            Some(base) => {
+                let path = format!("{}{}", base, url);
+                if path.is_empty() {
+                    "/".to_string()
+                } else {
+                    path
+                }
+            }
+            None => url,
+        };
+
+        if push {
+            history
+                .push_state_with_url(&JsValue::NULL, "", Some(&path))
+                .expect("push history");
+        } else {
+            history
+                .replace_state_with_url(&JsValue::NULL, "", Some(&path))
+                .expect("replace history");
+        }
+        let event = Event::new("popstate").unwrap();
+
+        web_sys::window()
+            .unwrap()
+            .dispatch_event(&event)
+            .expect("dispatch");
+    }
+}
+
+/// This hould only be used once per app
+///
+/// You can manually parse the route if you want, but the derived `parse` method on `Routable` will also work just fine
+pub fn use_router<R: Routable>(cx: Context, cfg: impl FnOnce(&str) -> R) -> Option<R> {
+    // for the web, attach to the history api
+    cx.use_hook(
+        |f| {
+            //
+            use gloo::events::EventListener;
+
+            let base_url = fetch_base_url();
+
+            let service: RouterService<R> = RouterService {
+                history: RefCell::new(vec![]),
+                base_ur: RefCell::new(base_url),
+            };
+
+            cx.provide_state(service);
+
+            let regenerate = cx.schedule_update();
+
+            // when "back" is called by the user, we want to to re-render the component
+            let listener = EventListener::new(&web_sys::window().unwrap(), "popstate", move |_| {
+                //
+                regenerate();
+            });
+        },
+        |f| {
+            //
+        },
+        |f| {
+            //
+        },
+    );
+
+    let router = use_router_service::<R>(cx)?;
+    Some(cfg(router.get_current_route()))
+}
+
+pub fn use_router_service<R: Routable>(cx: Context) -> Option<&Rc<RouterService<R>>> {
+    cx.use_hook(
+        |_| cx.consume_state::<RouterService<R>>(),
+        |f| f.as_ref(),
+        |f| {},
+    )
+}
+
+#[derive(Props)]
+pub struct LinkProps<'a, R: Routable> {
+    to: R,
+    children: ScopeChildren<'a>,
+}
+
+pub fn Link<'a, R: Routable>((cx, props): Scope<'a, LinkProps<'a, R>>) -> Element {
+    let service = use_router_service::<R>(cx)?;
+    cx.render(rsx! {
+        a {
+            href: format_args!("{}", props.to.to_path()),
+            onclick: move |_| service.push_route(props.to.clone()),
+            {&props.children},
+        }
+    })
+}
+
+pub trait Routable: Sized + Clone + 'static {
+    /// Converts path to an instance of the routes enum.
+    fn from_path(path: &str, params: &HashMap<&str, &str>) -> Option<Self>;
+
+    /// Converts the route to a string that can passed to the history API.
+    fn to_path(&self) -> String;
+
+    /// Lists all the available routes
+    fn routes() -> Vec<&'static str>;
+
+    /// The route to redirect to on 404
+    fn not_found_route() -> Option<Self>;
+
+    /// Match a route based on the path
+    fn recognize(pathname: &str) -> Option<Self>;
+}

+ 1 - 0
packages/router/src/platform.rs

@@ -0,0 +1 @@
+

+ 0 - 0
packages/router/src/platform/desktop.rs


+ 0 - 0
packages/router/src/platform/mobile.rs


+ 0 - 0
packages/router/src/platform/web.rs


+ 0 - 0
packages/router/src/service.rs


+ 31 - 0
packages/router/src/utils.rs

@@ -0,0 +1,31 @@
+use wasm_bindgen::JsCast;
+use web_sys::window;
+
+pub fn fetch_base_url() -> Option<String> {
+    match window()
+        .unwrap()
+        .document()
+        .unwrap()
+        .query_selector("base[href]")
+    {
+        Ok(Some(base)) => {
+            let base = JsCast::unchecked_into::<web_sys::HtmlBaseElement>(base).href();
+
+            let url = web_sys::Url::new(&base).unwrap();
+            let base = url.pathname();
+
+            let base = if base != "/" {
+                strip_slash_suffix(&base)
+            } else {
+                return None;
+            };
+
+            Some(base.to_string())
+        }
+        _ => None,
+    }
+}
+
+pub(crate) fn strip_slash_suffix(path: &str) -> &str {
+    path.strip_suffix('/').unwrap_or(path)
+}