From 3b9ac681061b66c2f100809cdeffd7b2498b9181 Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Wed, 2 Feb 2022 21:16:18 -0800 Subject: [PATCH 1/6] Upgrade dependencies --- docs/Cargo.toml | 6 +++--- docs/build.rs | 12 ++++++------ examples/hydrate/Cargo.toml | 2 +- packages/sycamore-macro/Cargo.toml | 2 +- packages/sycamore-reactive/Cargo.toml | 4 ++++ packages/sycamore-router-macro/Cargo.toml | 10 +++++----- packages/sycamore-router/Cargo.toml | 4 ++-- website/Cargo.toml | 8 ++++---- 8 files changed, 26 insertions(+), 22 deletions(-) diff --git a/docs/Cargo.toml b/docs/Cargo.toml index 5ee89ae9f..776f0e9d7 100644 --- a/docs/Cargo.toml +++ b/docs/Cargo.toml @@ -6,8 +6,8 @@ version = "0.1.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [build-dependencies] -pulldown-cmark = "0.8.0" -serde = { version = "1.0.130", features = ["derive"] } -serde_json = "1.0.71" +pulldown-cmark = "0.9.1" +serde = { version = "1.0.136", features = ["derive"] } +serde_json = "1.0.78" syntect = "4.6.0" walkdir = "2.3.2" diff --git a/docs/build.rs b/docs/build.rs index cf84a881f..497e6e84d 100644 --- a/docs/build.rs +++ b/docs/build.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::{fs, mem}; use pulldown_cmark::html::push_html; -use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag}; +use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag, HeadingLevel}; use serde::Serialize; use syntect::highlighting::ThemeSet; use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGenerator}; @@ -45,8 +45,8 @@ fn parse(path: &Path) -> Result> { let options = Options::all(); let parser = Parser::new_ext(&md, options).filter_map(|event| match event { - Event::Start(Tag::Heading(level)) => { - if level == 1 { + Event::Start(Tag::Heading(level, ..)) => { + if level == HeadingLevel::H1 { Some(event) } else { tmp = Some(Outline { @@ -56,14 +56,14 @@ fn parse(path: &Path) -> Result> { None } } - Event::End(Tag::Heading(level)) => { - if level == 1 { + Event::End(Tag::Heading(level, ..)) => { + if level == HeadingLevel::H1 { Some(event) } else { let tmp = tmp.take().unwrap(); let anchor = tmp.name.trim().to_lowercase().replace(" ", "-"); let name = tmp.name.clone(); - if level == 2 { + if level == HeadingLevel::H2 { outline_tmp.push(tmp); } else { let l = outline_tmp diff --git a/examples/hydrate/Cargo.toml b/examples/hydrate/Cargo.toml index 29040f5c9..e0822b91e 100644 --- a/examples/hydrate/Cargo.toml +++ b/examples/hydrate/Cargo.toml @@ -13,4 +13,4 @@ sycamore = { path = "../../packages/sycamore", features = [ "experimental-hydrate", "ssr", ] } -wasm-bindgen = "0.2.78" +wasm-bindgen = "0.2.79" diff --git a/packages/sycamore-macro/Cargo.toml b/packages/sycamore-macro/Cargo.toml index 1f7803df3..88424f185 100644 --- a/packages/sycamore-macro/Cargo.toml +++ b/packages/sycamore-macro/Cargo.toml @@ -23,7 +23,7 @@ syn = { version = "1.0.86", features = ["extra-traits"] } [dev-dependencies] sycamore = { path = "../sycamore", features = ["experimental-hydrate"] } -trybuild = "1.0.54" +trybuild = "1.0.55" [features] default = [] diff --git a/packages/sycamore-reactive/Cargo.toml b/packages/sycamore-reactive/Cargo.toml index 66be5ce4a..01f8e3697 100644 --- a/packages/sycamore-reactive/Cargo.toml +++ b/packages/sycamore-reactive/Cargo.toml @@ -11,6 +11,10 @@ repository = "https://github.com/sycamore-rs/sycamore" version = "0.8.0-beta.1" [dependencies] +futures = { version = "0.3.19", optional = true } indexmap = "1.8.0" serde = { version = "1.0.136", optional = true } slotmap = "1.0.6" + +[features] +default = [] diff --git a/packages/sycamore-router-macro/Cargo.toml b/packages/sycamore-router-macro/Cargo.toml index 1e6b6821c..d5daaf315 100644 --- a/packages/sycamore-router-macro/Cargo.toml +++ b/packages/sycamore-router-macro/Cargo.toml @@ -18,12 +18,12 @@ proc-macro = true [dependencies] nom = "7.1.0" -proc-macro2 = "1.0.32" -quote = "1.0.10" -syn = "1.0.81" +proc-macro2 = "1.0.36" +quote = "1.0.15" +syn = "1.0.86" unicode-xid = "0.2.2" [dev-dependencies] -expect-test = "1.1.0" +expect-test = "1.2.2" sycamore-router = { path = "../sycamore-router" } -trybuild = "1.0.52" +trybuild = "1.0.55" diff --git a/packages/sycamore-router/Cargo.toml b/packages/sycamore-router/Cargo.toml index 209de2c27..698d1da8b 100644 --- a/packages/sycamore-router/Cargo.toml +++ b/packages/sycamore-router/Cargo.toml @@ -16,7 +16,7 @@ version = "0.8.0-beta.1" [dependencies] sycamore = { path = "../sycamore", version = "0.8.0-beta.1" } sycamore-router-macro = { path = "../sycamore-router-macro", version = "0.8.0-beta.1" } -wasm-bindgen = "0.2.78" +wasm-bindgen = "0.2.79" [dependencies.web-sys] features = [ @@ -31,4 +31,4 @@ features = [ "Url", "Window", ] -version = "0.3.55" +version = "0.3.56" diff --git a/website/Cargo.toml b/website/Cargo.toml index 3b747be96..a06c13b8c 100644 --- a/website/Cargo.toml +++ b/website/Cargo.toml @@ -8,18 +8,18 @@ edition = "2021" [dependencies] console_error_panic_hook = "0.1.7" console_log = "0.2.0" -js-sys = "0.3.55" +js-sys = "0.3.56" log = "0.4.14" reqwasm = "0.4.0" serde-lite = { version = "0.2.0", features = ["derive"] } -serde_json = "1.0.71" +serde_json = "1.0.78" sycamore = { path = "../packages/sycamore", features = ["futures"] } sycamore-router = { path = "../packages/sycamore-router" } -wasm-bindgen = "0.2.78" +wasm-bindgen = "0.2.79" [dev-dependencies] docs = { path = "../docs" } [dependencies.web-sys] features = ["MediaQueryList", "Storage", "Window"] -version = "0.3.55" +version = "0.3.56" From 38801300be43c3d2757dd5b2b0c93e9cf2162893 Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Wed, 2 Feb 2022 21:19:06 -0800 Subject: [PATCH 2/6] Update trybuild tests --- .../tests/component/component-fail.stderr | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/sycamore-macro/tests/component/component-fail.stderr b/packages/sycamore-macro/tests/component/component-fail.stderr index 8c3dbae5b..1d8532b1d 100644 --- a/packages/sycamore-macro/tests/component/component-fail.stderr +++ b/packages/sycamore-macro/tests/component/component-fail.stderr @@ -2,7 +2,15 @@ error: function must return `sycamore::view::View` --> tests/component/component-fail.rs:5:1 | 5 | fn comp1() { - | ^^^^^^^^^^^^^^^^^^^ + | ^^ + +error: component must take at least one argument of type `sycamore::reactive::ScopeRef` + --> tests/component/component-fail.rs:9:1 + | +9 | #[component] + | ^^^^^^^^^^^^ + | + = note: this error originates in the attribute macro `component` (in Nightly builds, run with -Z macro-backtrace for more info) error: const functions can't be components --> tests/component/component-fail.rs:15:1 From 07874d550714d31346195de6d69f14ab570b9be4 Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Sat, 5 Feb 2022 11:02:13 -0800 Subject: [PATCH 3/6] Async components! --- Cargo.toml | 1 + docs/build.rs | 2 +- docs/versioned_docs/v0.7/advanced/routing.md | 2 +- examples/http-request/Cargo.toml | 2 +- examples/http-request/src/main.rs | 26 +++---- packages/sycamore-futures/Cargo.toml | 21 ++++++ packages/sycamore-futures/src/lib.rs | 46 ++++++++++++ packages/sycamore-macro/src/component/mod.rs | 77 ++++++++++++++++++-- packages/sycamore-reactive/Cargo.toml | 3 +- packages/sycamore-reactive/src/effect.rs | 12 ++- packages/sycamore-reactive/src/lib.rs | 11 ++- packages/sycamore/Cargo.toml | 4 +- packages/sycamore/src/futures.rs | 2 + packages/sycamore/src/lib.rs | 7 +- packages/sycamore/src/suspense.rs | 24 ++++++ website/Cargo.toml | 2 +- 16 files changed, 204 insertions(+), 38 deletions(-) create mode 100644 packages/sycamore-futures/Cargo.toml create mode 100644 packages/sycamore-futures/src/lib.rs create mode 100644 packages/sycamore/src/suspense.rs diff --git a/Cargo.toml b/Cargo.toml index 576823328..055faaf14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ # Packages "packages/sycamore", + "packages/sycamore-futures", "packages/sycamore-macro", "packages/sycamore-reactive", "packages/sycamore-router", diff --git a/docs/build.rs b/docs/build.rs index 497e6e84d..62b026636 100644 --- a/docs/build.rs +++ b/docs/build.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::{fs, mem}; use pulldown_cmark::html::push_html; -use pulldown_cmark::{CodeBlockKind, CowStr, Event, Options, Parser, Tag, HeadingLevel}; +use pulldown_cmark::{CodeBlockKind, CowStr, Event, HeadingLevel, Options, Parser, Tag}; use serde::Serialize; use syntect::highlighting::ThemeSet; use syntect::html::{css_for_theme_with_class_style, ClassStyle, ClassedHTMLGenerator}; diff --git a/docs/versioned_docs/v0.7/advanced/routing.md b/docs/versioned_docs/v0.7/advanced/routing.md index 70bac3dd6..95ee4ad95 100644 --- a/docs/versioned_docs/v0.7/advanced/routing.md +++ b/docs/versioned_docs/v0.7/advanced/routing.md @@ -268,7 +268,7 @@ the data. This will cause the router to wait until the data is loaded before ren removing the need for some "Loading..." indicator. `spawn_local_in_scope` is a simple wrapper around `wasm_bindgen_futures::spawn_local` that extends -the current scope into inside the `async` block. Make sure you enable the `"futures"` feature on +the current scope into inside the `async` block. Make sure you enable the `"suspense"` feature on `sycamore`. ```rust diff --git a/examples/http-request/Cargo.toml b/examples/http-request/Cargo.toml index 7c7918dde..94c125a09 100644 --- a/examples/http-request/Cargo.toml +++ b/examples/http-request/Cargo.toml @@ -12,4 +12,4 @@ console_log = "0.2.0" log = "0.4.14" reqwasm = "0.4.0" serde = "1.0.136" -sycamore = { path = "../../packages/sycamore", features = ["futures"] } +sycamore = { path = "../../packages/sycamore", features = ["suspense"] } diff --git a/examples/http-request/src/main.rs b/examples/http-request/src/main.rs index f863b9d0a..dfac44a00 100644 --- a/examples/http-request/src/main.rs +++ b/examples/http-request/src/main.rs @@ -1,7 +1,6 @@ use anyhow::Result; use reqwasm::http::Request; use serde::{Deserialize, Serialize}; -use sycamore::futures::ScopeFuturesExt; use sycamore::prelude::*; // API that counts visits to the web-page @@ -21,14 +20,16 @@ async fn fetch_visits(id: &str) -> Result { } #[component] -fn RenderVisits(ctx: ScopeRef, count: u64) -> View { +async fn VisitsCount(ctx: ScopeRef<'_>) -> View { + let id = "sycamore-visits-counter"; + let visits = fetch_visits(id).await.unwrap_or_default(); + view! { ctx, div { - p { "Page Visit Counter" } p { "Total visits: " span(class="text-green-500") { - (count) + (visits.value) } } } @@ -37,17 +38,12 @@ fn RenderVisits(ctx: ScopeRef, count: u64) -> View { #[component] fn App(ctx: ScopeRef) -> View { - let count = ctx.create_resource(async move { - let website_id = "page-visit-counter-tailwindcss.tyz"; - let visits = fetch_visits(website_id).await.unwrap_or_default(); - visits.value - }); - - view! { ctx, (if let Some(count) = *count.get() { - view! { ctx, RenderVisits(count) } - } else { - view! { ctx, } - })} + view! { ctx, + div { + p { "Page Visit Counter" } + VisitsCount {} + } + } } fn main() { diff --git a/packages/sycamore-futures/Cargo.toml b/packages/sycamore-futures/Cargo.toml new file mode 100644 index 000000000..51bfc56bb --- /dev/null +++ b/packages/sycamore-futures/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "sycamore-futures" +categories = ["gui", "wasm", "web-programming"] +description = "A VDOM-less web library with fine grained reactivity" +edition = "2021" +homepage = "https://github.com/sycamore-rs/sycamore" +keywords = ["wasm", "gui", "reactive"] +license = "MIT" +readme = "../../README.md" +repository = "https://github.com/sycamore-rs/sycamore" +version = "0.8.0-beta.1" + +[dependencies] +futures = { version = "0.3.19" } +sycamore-reactive = { path = "../sycamore-reactive", version = "0.8.0-beta.1" } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen-futures = "0.4.29" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tokio = { version = "1.16.1", features = ["rt"] } diff --git a/packages/sycamore-futures/src/lib.rs b/packages/sycamore-futures/src/lib.rs new file mode 100644 index 000000000..58a09586b --- /dev/null +++ b/packages/sycamore-futures/src/lib.rs @@ -0,0 +1,46 @@ +//! Futures support for reactive scopes. + +use std::pin::Pin; + +use futures::future::abortable; +use futures::Future; +use sycamore_reactive::Scope; + +/// If running on `wasm32` target, does nothing. Otherwise creates a new `tokio::task::LocalSet` +/// scope. +pub async fn provide_executor_scope(f: impl Future) -> U { + #[cfg(target_arch = "wasm32")] + { + f.await + } + #[cfg(not(target_arch = "wasm32"))] + { + let local = tokio::task::LocalSet::new(); + local.run_until(f).await + } +} + +pub trait ScopeSpawnFuture<'a> { + /// Spawns a `!Send` future on the current scope. If the scope is destroyed before the future is + /// completed, it is aborted immediately. + fn spawn_future(&self, f: impl Future + 'a); +} + +impl<'a> ScopeSpawnFuture<'a> for Scope<'a> { + fn spawn_future(&self, f: impl Future + 'a) { + let boxed: Pin + 'a>> = Box::pin(f); + // SAFETY: We are just transmuting the lifetime here so that we can spawn the future. + // This is safe because we wrap the future in an `Abortable` future which will be + // immediately aborted once the reactive scope is dropped. + let extended: Pin + 'static>> = + unsafe { std::mem::transmute(boxed) }; + let (abortable, handle) = abortable(extended); + self.on_cleanup(move || handle.abort()); + #[cfg(not(target_arch = "wasm32"))] + tokio::task::spawn_local(abortable); + #[cfg(target_arch = "wasm32")] + wasm_bindgen_futures::spawn_local(async move { + let _ = abortable.await; + }); + } +} diff --git a/packages/sycamore-macro/src/component/mod.rs b/packages/sycamore-macro/src/component/mod.rs index 6a444bf5e..3b233c9a9 100644 --- a/packages/sycamore-macro/src/component/mod.rs +++ b/packages/sycamore-macro/src/component/mod.rs @@ -1,10 +1,10 @@ //! The `#[component]` attribute macro implementation. use proc_macro2::TokenStream; -use quote::quote; +use quote::{format_ident, quote, ToTokens}; use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; -use syn::{parse_quote, FnArg, Item, ItemFn, Result, ReturnType, Type, TypeTuple}; +use syn::{parse_quote, FnArg, Item, ItemFn, Result, ReturnType, Signature, Type, TypeTuple, Pat, Expr}; pub struct ComponentFunction { pub f: ItemFn, @@ -88,11 +88,72 @@ impl Parse for ComponentFunction { } } -pub fn component_impl(comp: ComponentFunction) -> Result { - let ComponentFunction { f } = comp; +impl ToTokens for ComponentFunction { + fn to_tokens(&self, tokens: &mut TokenStream) { + let ComponentFunction { f } = self; + let ItemFn { + attrs, + vis, + sig, + block, + } = &f; + + if sig.asyncness.is_some() { + let inputs = &sig.inputs; + let args: Vec = inputs.iter().map(|x| match x { + FnArg::Typed(t) => match &*t.pat { + Pat::Ident(id) => { + let id = &id.ident; + parse_quote! { #id } + }, + Pat::Wild(_) => parse_quote!( () ), + _ => panic!("unexpected pattern"), // TODO + }, + FnArg::Receiver(_) => unreachable!(), + }).collect::>(); + let non_async_sig = Signature { + asyncness: None, + ..sig.clone() + }; + let inner_ident = format_ident!("{}_inner", sig.ident); + let inner_sig = Signature { + ident: inner_ident.clone(), + ..sig.clone() + }; + let ctx = match inputs.first().unwrap() { + FnArg::Typed(t) => match &*t.pat { + Pat::Ident(id) => &id.ident, + _ => unreachable!(), + } + FnArg::Receiver(_) => unreachable!(), + }; + tokens.extend(quote! { + #[allow(non_snake_case)] + #(#attrs)* + #vis #non_async_sig { + #[allow(non_snake_case)] + #inner_sig #block + + let __dyn = #ctx.create_signal(::sycamore::view::View::empty()); + let __view = ::sycamore::view! { #ctx, (__dyn.get().as_ref().clone()) }; + + <_ as ::sycamore::futures::ScopeSpawnFuture>::spawn_future(#ctx, async move { + let __async_view = #inner_ident(#(#args),*).await; + __dyn.set(__async_view); + }); + + __view + } + }); + } else { + tokens.extend(quote! { + #[allow(non_snake_case)] + #f + }); + } + } +} - Ok(quote! { - #[allow(non_snake_case)] - #f - }) +pub fn component_impl(comp: ComponentFunction) -> Result { + Ok(comp.to_token_stream()) } diff --git a/packages/sycamore-reactive/Cargo.toml b/packages/sycamore-reactive/Cargo.toml index 01f8e3697..d3975dfe2 100644 --- a/packages/sycamore-reactive/Cargo.toml +++ b/packages/sycamore-reactive/Cargo.toml @@ -11,10 +11,11 @@ repository = "https://github.com/sycamore-rs/sycamore" version = "0.8.0-beta.1" [dependencies] -futures = { version = "0.3.19", optional = true } indexmap = "1.8.0" serde = { version = "1.0.136", optional = true } slotmap = "1.0.6" [features] default = [] + + diff --git a/packages/sycamore-reactive/src/effect.rs b/packages/sycamore-reactive/src/effect.rs index 92cd47243..b6bfb0f07 100644 --- a/packages/sycamore-reactive/src/effect.rs +++ b/packages/sycamore-reactive/src/effect.rs @@ -110,10 +110,12 @@ impl<'a> Scope<'a> { // we need to add backlinks from the signal to the effect, so that // updating the signal will trigger the effect. for emitter in &boxed.dependencies { - // The SignalEmitter might have been destroyed between when the signal was accessed and now. + // The SignalEmitter might have been destroyed between when the signal was + // accessed and now. if let Some(emitter) = emitter.0.upgrade() { - // SAFETY: When the effect is destroyed or when the emitter is dropped, this - // link will be destroyed to prevent dangling references. + // SAFETY: When the effect is destroyed or when the emitter is dropped, + // this link will be destroyed to prevent + // dangling references. emitter.subscribe(Rc::downgrade(unsafe { std::mem::transmute(&boxed.cb) })); @@ -379,7 +381,9 @@ mod tests { trigger.set(()); assert_eq!(*counter.get(), 2); - unsafe { disposer.dispose(); } + unsafe { + disposer.dispose(); + } trigger.set(()); assert_eq!(*counter.get(), 2); // inner effect should be destroyed and thus not executed }); diff --git a/packages/sycamore-reactive/src/lib.rs b/packages/sycamore-reactive/src/lib.rs index 8599fe7c8..8c7d053bd 100644 --- a/packages/sycamore-reactive/src/lib.rs +++ b/packages/sycamore-reactive/src/lib.rs @@ -349,7 +349,8 @@ impl<'a> Scope<'a> { }) } - /// Cleanup the resources owned by the [`Scope`]. For more details, see [`ScopeDisposer::dispose`]. + /// Cleanup the resources owned by the [`Scope`]. For more details, see + /// [`ScopeDisposer::dispose`]. /// /// This is automatically called in [`Drop`] /// However, [`dispose`](Self::dispose) only needs to take `&self` instead of `&mut self`. @@ -438,7 +439,9 @@ mod tests { dbg!(r); }) }); - unsafe { disposer.dispose(); } + unsafe { + disposer.dispose(); + } } #[test] @@ -451,7 +454,9 @@ mod tests { }); }); assert!(!*cleanup_called.get()); - unsafe { disposer.dispose(); } + unsafe { + disposer.dispose(); + } assert!(*cleanup_called.get()); }); } diff --git a/packages/sycamore/Cargo.toml b/packages/sycamore/Cargo.toml index e4e31ac8e..e80999c58 100644 --- a/packages/sycamore/Cargo.toml +++ b/packages/sycamore/Cargo.toml @@ -14,10 +14,12 @@ version = "0.8.0-beta.1" [dependencies] ahash = "0.7.6" +futures = { version = "0.3.19", optional = true } html-escape = { version = "0.2.9", optional = true } indexmap = { version = "1.8.0", features = ["std"] } js-sys = "0.3.56" once_cell = { version = "1.9.0", optional = true } +sycamore-futures = { path = "../sycamore-futures", version = "0.8.0-beta.1", optional = true } sycamore-macro = { path = "../sycamore-macro", version = "0.8.0-beta.1" } sycamore-reactive = { path = "../sycamore-reactive", version = "0.8.0-beta.1" } wasm-bindgen = "0.2.79" @@ -59,8 +61,8 @@ dom = [] experimental-builder-agnostic = [] experimental-builder-html = ["experimental-builder-agnostic"] experimental-hydrate = ["sycamore-macro/experimental-hydrate"] -futures = ["wasm-bindgen-futures"] ssr = ["html-escape", "once_cell", "experimental-hydrate", "sycamore-macro/ssr"] +suspense = ["futures", "wasm-bindgen-futures", "sycamore-futures"] serde = ["sycamore-reactive/serde"] wasm-bindgen-interning = ["wasm-bindgen/enable-interning"] diff --git a/packages/sycamore/src/futures.rs b/packages/sycamore/src/futures.rs index 2d3f3a64d..c4e993746 100644 --- a/packages/sycamore/src/futures.rs +++ b/packages/sycamore/src/futures.rs @@ -1,6 +1,8 @@ use std::future::Future; pub use wasm_bindgen_futures::*; +// Re-export `sycamore-futures` crate. +pub use sycamore_futures::*; use crate::prelude::*; diff --git a/packages/sycamore/src/lib.rs b/packages/sycamore/src/lib.rs index 86cc03e89..19bbe8f6a 100644 --- a/packages/sycamore/src/lib.rs +++ b/packages/sycamore/src/lib.rs @@ -12,7 +12,7 @@ //! - `experimental-builder-html` - Enables the HTML specific backend builder API. Also enables //! `experimental-builder-agnostic`. //! - `experimental-hydrate` - Enables client-side hydration support. -//! - `futures` - Enables wrappers around `wasm-bindgen-futures` to make it easier to extend a +//! - `suspense` - Enables wrappers around `wasm-bindgen-futures` to make it easier to extend a //! reactive scope into an `async` function. //! - `ssr` - Enables rendering templates to static strings (useful for Server Side Rendering / //! Pre-rendering). @@ -38,12 +38,14 @@ pub mod builder; pub mod component; pub mod easing; pub mod flow; -#[cfg(feature = "futures")] +#[cfg(feature = "suspense")] pub mod futures; pub mod generic_node; pub mod motion; pub mod noderef; pub mod portal; +#[cfg(feature = "suspense")] +pub mod suspense; pub mod utils; pub mod view; @@ -69,6 +71,7 @@ pub mod prelude { #[cfg(feature = "ssr")] pub use crate::generic_node::SsrNode; + pub use crate::component::Children; pub use crate::flow::*; pub use crate::generic_node::{GenericNode, Html}; pub use crate::noderef::{NodeRef, ScopeCreateNodeRef}; diff --git a/packages/sycamore/src/suspense.rs b/packages/sycamore/src/suspense.rs new file mode 100644 index 000000000..1dbed4b20 --- /dev/null +++ b/packages/sycamore/src/suspense.rs @@ -0,0 +1,24 @@ +//! Suspense with first class `async`/`await` support. + +use crate::prelude::*; + +struct SuspenseState {} + +/// Props for [`Suspense`]. +#[derive(Prop)] +pub struct SuspenseProps<'a, G: GenericNode> { + children: Children<'a, G>, +} + +/// TODO: docs +#[component] +pub fn Suspense<'a, G: GenericNode>(ctx: ScopeRef<'a>, props: SuspenseProps<'a, G>) -> View { + // Provide suspense state. + let state = SuspenseState {}; + ctx.provide_context(state); + + let children = props.children.call(ctx); + view! { ctx, + (children) + } +} diff --git a/website/Cargo.toml b/website/Cargo.toml index a06c13b8c..1bc4517fa 100644 --- a/website/Cargo.toml +++ b/website/Cargo.toml @@ -13,7 +13,7 @@ log = "0.4.14" reqwasm = "0.4.0" serde-lite = { version = "0.2.0", features = ["derive"] } serde_json = "1.0.78" -sycamore = { path = "../packages/sycamore", features = ["futures"] } +sycamore = { path = "../packages/sycamore", features = ["suspense"] } sycamore-router = { path = "../packages/sycamore-router" } wasm-bindgen = "0.2.79" From aa95b2370faa69453e07cf61f79ae727f5e27107 Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Sat, 5 Feb 2022 12:36:59 -0800 Subject: [PATCH 4/6] ContextProvider --- packages/sycamore-macro/src/view/codegen.rs | 5 +++- packages/sycamore/src/component.rs | 4 +++ packages/sycamore/src/context.rs | 27 +++++++++++++++++++++ packages/sycamore/src/lib.rs | 1 + packages/sycamore/src/suspense.rs | 10 ++++---- 5 files changed, 41 insertions(+), 6 deletions(-) create mode 100644 packages/sycamore/src/context.rs diff --git a/packages/sycamore-macro/src/view/codegen.rs b/packages/sycamore-macro/src/view/codegen.rs index edc18f61d..f326df02d 100644 --- a/packages/sycamore-macro/src/view/codegen.rs +++ b/packages/sycamore-macro/src/view/codegen.rs @@ -470,7 +470,10 @@ impl Codegen { let view_root = self.view_root(children); props_quoted.extend(quote! { .children( - ::sycamore::component::Children::new(move |__ctx| #view_root) + ::sycamore::component::Children::new(move |__ctx| { + let __ctx: &ScopeRef = &__ctx; + #view_root + }) ) }); } diff --git a/packages/sycamore/src/component.rs b/packages/sycamore/src/component.rs index ab8f21186..f0992f404 100644 --- a/packages/sycamore/src/component.rs +++ b/packages/sycamore/src/component.rs @@ -74,6 +74,10 @@ impl<'a, G: GenericNode> Children<'a, G> { view.unwrap() } + pub fn call_with_bounded_scope(self, ctx: BoundedScopeRef<'_, 'a>) -> View { + (self.f)(ctx) + } + /// Create a new [`Children`] from a closure. pub fn new(f: impl FnOnce(BoundedScopeRef<'_, 'a>) -> View + 'a) -> Self { Self { f: Box::new(f) } diff --git a/packages/sycamore/src/context.rs b/packages/sycamore/src/context.rs new file mode 100644 index 000000000..d67871fad --- /dev/null +++ b/packages/sycamore/src/context.rs @@ -0,0 +1,27 @@ +//! Utility components for providing contexts. + +use crate::prelude::*; + +#[derive(Prop)] +pub struct ContextProviderProps<'a, T, G: GenericNode> { + value: T, + children: Children<'a, G>, +} + +#[component] +pub fn ContextProvider<'a, T: 'static, G: GenericNode>( + ctx: ScopeRef<'a>, + props: ContextProviderProps<'a, T, G>, +) -> View { + let mut view = None; + ctx.create_child_scope(|ctx| { + ctx.provide_context(props.value); + // SAFETY: `props.children` takes the same parameter as argument passed to ctx.create_child_scope + view = Some( + props + .children + .call_with_bounded_scope(unsafe { std::mem::transmute(ctx) }), + ); + }); + view.unwrap() +} diff --git a/packages/sycamore/src/lib.rs b/packages/sycamore/src/lib.rs index 19bbe8f6a..1fbde7ef6 100644 --- a/packages/sycamore/src/lib.rs +++ b/packages/sycamore/src/lib.rs @@ -36,6 +36,7 @@ extern crate self as sycamore; #[cfg(feature = "experimental-builder-agnostic")] pub mod builder; pub mod component; +pub mod context; pub mod easing; pub mod flow; #[cfg(feature = "suspense")] diff --git a/packages/sycamore/src/suspense.rs b/packages/sycamore/src/suspense.rs index 1dbed4b20..84e0e0ceb 100644 --- a/packages/sycamore/src/suspense.rs +++ b/packages/sycamore/src/suspense.rs @@ -1,5 +1,6 @@ //! Suspense with first class `async`/`await` support. +use crate::context::ContextProvider; use crate::prelude::*; struct SuspenseState {} @@ -13,12 +14,11 @@ pub struct SuspenseProps<'a, G: GenericNode> { /// TODO: docs #[component] pub fn Suspense<'a, G: GenericNode>(ctx: ScopeRef<'a>, props: SuspenseProps<'a, G>) -> View { - // Provide suspense state. - let state = SuspenseState {}; - ctx.provide_context(state); - let children = props.children.call(ctx); view! { ctx, - (children) + ContextProvider { + value: SuspenseState {}, + (children) + } } } From f777fe76aa9d583b7b5722df1842050f0114bbd4 Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Sat, 5 Feb 2022 14:53:52 -0800 Subject: [PATCH 5/6] Add Suspense --- examples/http-request/src/main.rs | 6 ++- packages/sycamore-macro/src/component/mod.rs | 35 +++++++++------ packages/sycamore/src/component.rs | 14 +++--- packages/sycamore/src/context.rs | 6 ++- packages/sycamore/src/suspense.rs | 45 +++++++++++++++++--- 5 files changed, 75 insertions(+), 31 deletions(-) diff --git a/examples/http-request/src/main.rs b/examples/http-request/src/main.rs index dfac44a00..52bb4e0c2 100644 --- a/examples/http-request/src/main.rs +++ b/examples/http-request/src/main.rs @@ -2,6 +2,7 @@ use anyhow::Result; use reqwasm::http::Request; use serde::{Deserialize, Serialize}; use sycamore::prelude::*; +use sycamore::suspense::Suspense; // API that counts visits to the web-page const API_BASE_URL: &str = "https://api.countapi.xyz/hit"; @@ -41,7 +42,10 @@ fn App(ctx: ScopeRef) -> View { view! { ctx, div { p { "Page Visit Counter" } - VisitsCount {} + Suspense { + fallback: view! { ctx, "Loading..." }, + VisitsCount {} + } } } } diff --git a/packages/sycamore-macro/src/component/mod.rs b/packages/sycamore-macro/src/component/mod.rs index 3b233c9a9..92ee6eb46 100644 --- a/packages/sycamore-macro/src/component/mod.rs +++ b/packages/sycamore-macro/src/component/mod.rs @@ -4,7 +4,9 @@ use proc_macro2::TokenStream; use quote::{format_ident, quote, ToTokens}; use syn::parse::{Parse, ParseStream}; use syn::spanned::Spanned; -use syn::{parse_quote, FnArg, Item, ItemFn, Result, ReturnType, Signature, Type, TypeTuple, Pat, Expr}; +use syn::{ + parse_quote, Expr, FnArg, Item, ItemFn, Pat, Result, ReturnType, Signature, Type, TypeTuple, +}; pub struct ComponentFunction { pub f: ItemFn, @@ -100,17 +102,20 @@ impl ToTokens for ComponentFunction { if sig.asyncness.is_some() { let inputs = &sig.inputs; - let args: Vec = inputs.iter().map(|x| match x { - FnArg::Typed(t) => match &*t.pat { - Pat::Ident(id) => { - let id = &id.ident; - parse_quote! { #id } + let args: Vec = inputs + .iter() + .map(|x| match x { + FnArg::Typed(t) => match &*t.pat { + Pat::Ident(id) => { + let id = &id.ident; + parse_quote! { #id } + } + Pat::Wild(_) => parse_quote!(()), + _ => panic!("unexpected pattern"), // TODO }, - Pat::Wild(_) => parse_quote!( () ), - _ => panic!("unexpected pattern"), // TODO - }, - FnArg::Receiver(_) => unreachable!(), - }).collect::>(); + FnArg::Receiver(_) => unreachable!(), + }) + .collect::>(); let non_async_sig = Signature { asyncness: None, ..sig.clone() @@ -124,7 +129,7 @@ impl ToTokens for ComponentFunction { FnArg::Typed(t) => match &*t.pat { Pat::Ident(id) => &id.ident, _ => unreachable!(), - } + }, FnArg::Receiver(_) => unreachable!(), }; tokens.extend(quote! { @@ -138,8 +143,10 @@ impl ToTokens for ComponentFunction { let __view = ::sycamore::view! { #ctx, (__dyn.get().as_ref().clone()) }; <_ as ::sycamore::futures::ScopeSpawnFuture>::spawn_future(#ctx, async move { - let __async_view = #inner_ident(#(#args),*).await; - __dyn.set(__async_view); + ::sycamore::suspense::suspense_scope(#ctx, async move { + let __async_view = #inner_ident(#(#args),*).await; + __dyn.set(__async_view); + }).await }); __view diff --git a/packages/sycamore/src/component.rs b/packages/sycamore/src/component.rs index f0992f404..18ce13dcc 100644 --- a/packages/sycamore/src/component.rs +++ b/packages/sycamore/src/component.rs @@ -64,14 +64,12 @@ where } impl<'a, G: GenericNode> Children<'a, G> { - pub fn call(self, ctx: ScopeRef<'a>) -> View { - let mut view = None; - let _ = ctx.create_child_scope(|ctx| { - // SAFETY: `self.f` takes the same parameter as ctx.create_child_scope - let tmp = (self.f)(unsafe { std::mem::transmute(ctx) }); - view = Some(tmp); - }); - view.unwrap() + pub fn call<'b>(self, ctx: ScopeRef<'b>) -> View + where + 'a: 'b, + { + let bounded = BoundedScopeRef::<'b, 'a>::new(ctx); + (self.f)(bounded) } pub fn call_with_bounded_scope(self, ctx: BoundedScopeRef<'_, 'a>) -> View { diff --git a/packages/sycamore/src/context.rs b/packages/sycamore/src/context.rs index d67871fad..35d3c8c36 100644 --- a/packages/sycamore/src/context.rs +++ b/packages/sycamore/src/context.rs @@ -8,6 +8,9 @@ pub struct ContextProviderProps<'a, T, G: GenericNode> { children: Children<'a, G>, } +/// Provides a context. Unlike [`provide_context`](Scope::provide_context), using the component +/// instead will create a new child scope, preventing conflicts with existing contexts of the same +/// type. #[component] pub fn ContextProvider<'a, T: 'static, G: GenericNode>( ctx: ScopeRef<'a>, @@ -16,7 +19,8 @@ pub fn ContextProvider<'a, T: 'static, G: GenericNode>( let mut view = None; ctx.create_child_scope(|ctx| { ctx.provide_context(props.value); - // SAFETY: `props.children` takes the same parameter as argument passed to ctx.create_child_scope + // SAFETY: `props.children` takes the same parameter as argument passed to + // ctx.create_child_scope view = Some( props .children diff --git a/packages/sycamore/src/suspense.rs b/packages/sycamore/src/suspense.rs index 84e0e0ceb..90708adec 100644 --- a/packages/sycamore/src/suspense.rs +++ b/packages/sycamore/src/suspense.rs @@ -1,24 +1,55 @@ //! Suspense with first class `async`/`await` support. -use crate::context::ContextProvider; +use futures::Future; + use crate::prelude::*; -struct SuspenseState {} +#[derive(Clone)] +struct SuspenseState { + async_count: RcSignal, +} /// Props for [`Suspense`]. #[derive(Prop)] pub struct SuspenseProps<'a, G: GenericNode> { + #[builder(default)] + fallback: View, children: Children<'a, G>, } /// TODO: docs #[component] pub fn Suspense<'a, G: GenericNode>(ctx: ScopeRef<'a>, props: SuspenseProps<'a, G>) -> View { - let children = props.children.call(ctx); + let state = SuspenseState { + async_count: create_rc_signal(0), + }; + let ready = ctx.create_selector({ + let state = state.clone(); + move || *state.async_count.get() == 0 + }); + ctx.provide_context(state); + // FIXME: use ContextProvider + // view! { ctx, + // ContextProvider { + // value: state, + // children: Children::new(move |_| { + let v = props.children.call(ctx); + view! { ctx, - ContextProvider { - value: SuspenseState {}, - (children) - } + (if *ready.get() { v.clone() } else { props.fallback.clone() }) + } + // }) + // } + // } +} + +pub async fn suspense_scope(ctx: ScopeRef<'_>, f: impl Future) -> U { + if let Some(state) = ctx.try_use_context::() { + state.async_count.set(*state.async_count.get() + 1); + let ret = f.await; + state.async_count.set(*state.async_count.get() - 1); + ret + } else { + f.await } } From 6fbdc5aaaffccc148efbe8d864b50ca6b5f08e8f Mon Sep 17 00:00:00 2001 From: Luke Chu <37006668+lukechu10@users.noreply.github.com> Date: Sat, 5 Feb 2022 15:53:26 -0800 Subject: [PATCH 6/6] Add render_to_string_await_suspense --- packages/sycamore-reactive/src/context.rs | 2 + packages/sycamore/Cargo.toml | 3 +- .../sycamore/src/generic_node/ssr_node.rs | 46 +++++++++ packages/sycamore/src/suspense.rs | 97 ++++++++++++++++--- 4 files changed, 136 insertions(+), 12 deletions(-) diff --git a/packages/sycamore-reactive/src/context.rs b/packages/sycamore-reactive/src/context.rs index 0d938c455..d1d373e6e 100644 --- a/packages/sycamore-reactive/src/context.rs +++ b/packages/sycamore-reactive/src/context.rs @@ -15,6 +15,7 @@ impl<'a> Scope<'a> { /// This method panics if a context with the same type exists already in this scope. /// Note that if a context with the same type exists in a parent scope, the new context will /// shadow the old context. + #[track_caller] pub fn provide_context(&'a self, value: T) { let value = self.create_ref(value); self.provide_context_ref(value); @@ -32,6 +33,7 @@ impl<'a> Scope<'a> { /// This method panics if a context with the same type exists already in this scope. /// Note that if a context with the same type exists in a parent scope, the new context will /// shadow the old context. + #[track_caller] pub fn provide_context_ref(&'a self, value: &'a T) { let type_id = TypeId::of::(); if self.contexts.borrow_mut().insert(type_id, value).is_some() { diff --git a/packages/sycamore/Cargo.toml b/packages/sycamore/Cargo.toml index e80999c58..174408c56 100644 --- a/packages/sycamore/Cargo.toml +++ b/packages/sycamore/Cargo.toml @@ -19,12 +19,12 @@ html-escape = { version = "0.2.9", optional = true } indexmap = { version = "1.8.0", features = ["std"] } js-sys = "0.3.56" once_cell = { version = "1.9.0", optional = true } +paste = "1.0.6" sycamore-futures = { path = "../sycamore-futures", version = "0.8.0-beta.1", optional = true } sycamore-macro = { path = "../sycamore-macro", version = "0.8.0-beta.1" } sycamore-reactive = { path = "../sycamore-reactive", version = "0.8.0-beta.1" } wasm-bindgen = "0.2.79" wasm-bindgen-futures = { version = "0.4.29", optional = true } -paste = "1.0.6" [dependencies.lexical] version = "6.0.1" @@ -53,6 +53,7 @@ version = "0.3.56" [dev-dependencies] criterion = "0.3.5" expect-test = "1.2.2" +tokio = { version = "1.16.1", features = ["macros", "rt"] } wasm-bindgen-test = "0.3.29" [features] diff --git a/packages/sycamore/src/generic_node/ssr_node.rs b/packages/sycamore/src/generic_node/ssr_node.rs index 83d4e5ac5..db35be470 100644 --- a/packages/sycamore/src/generic_node/ssr_node.rs +++ b/packages/sycamore/src/generic_node/ssr_node.rs @@ -452,6 +452,52 @@ pub fn render_to_string(view: impl FnOnce(ScopeRef<'_>) -> View) -> Str ret } +/// Render a [`View`] into a static [`String`]. Useful +/// for rendering to a string on the server side. +/// +/// Waits for suspense to be loaded before returning. +/// +/// _This API requires the following crate features to be activated: `suspense`, `ssr`_ +#[cfg(feature = "suspense")] +pub async fn render_to_string_await_suspense( + view: impl FnOnce(ScopeRef<'_>) -> View + 'static, +) -> String { + use futures::channel::oneshot; + use sycamore_futures::ScopeSpawnFuture; + + let mut ret = String::new(); + let v = Rc::new(RefCell::new(None)); + let (sender, receiver) = oneshot::channel(); + let disposer = create_scope({ + let v = Rc::clone(&v); + move |ctx| { + ctx.spawn_future(async move { + *v.borrow_mut() = Some( + crate::suspense::await_suspense(ctx, async { + with_hydration_context(|| view(ctx)) + }) + .await, + ); + sender + .send(()) + .expect("receiving end should not be dropped"); + }); + } + }); + receiver.await.expect("rendering should complete"); + let v = v.borrow().clone().unwrap(); + for node in v.flatten() { + node.write_to_string(&mut ret); + } + + // SAFETY: we are done with the scope now. + unsafe { + disposer.dispose(); + } + + ret +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/sycamore/src/suspense.rs b/packages/sycamore/src/suspense.rs index 90708adec..c1c9c7958 100644 --- a/packages/sycamore/src/suspense.rs +++ b/packages/sycamore/src/suspense.rs @@ -1,7 +1,11 @@ //! Suspense with first class `async`/`await` support. +use std::cell::{Cell, RefCell}; + +use futures::channel::oneshot; use futures::Future; +use crate::context::ContextProvider; use crate::prelude::*; #[derive(Clone)] @@ -20,6 +24,8 @@ pub struct SuspenseProps<'a, G: GenericNode> { /// TODO: docs #[component] pub fn Suspense<'a, G: GenericNode>(ctx: ScopeRef<'a>, props: SuspenseProps<'a, G>) -> View { + let outer_state = ctx.try_use_context::(); + let state = SuspenseState { async_count: create_rc_signal(0), }; @@ -27,20 +33,34 @@ pub fn Suspense<'a, G: GenericNode>(ctx: ScopeRef<'a>, props: SuspenseProps<'a, let state = state.clone(); move || *state.async_count.get() == 0 }); - ctx.provide_context(state); // FIXME: use ContextProvider - // view! { ctx, - // ContextProvider { - // value: state, - // children: Children::new(move |_| { - let v = props.children.call(ctx); - view! { ctx, - (if *ready.get() { v.clone() } else { props.fallback.clone() }) + ContextProvider { + value: state, + children: Children::new(move |_| { + let v = props.children.call(ctx); + + if let Some(outer_state) = outer_state { + outer_state + .async_count + .set(*outer_state.async_count.get() + 1); + let completed = ctx.create_ref(Cell::new(false)); + ctx.create_effect(|| { + if !completed.get() && *ready.get() { + outer_state + .async_count + .set(*outer_state.async_count.get() - 1); + completed.set(true); + } + }); + } + + view! { ctx, + (if *ready.get() { v.clone() } else { props.fallback.clone() }) + } + }) + } } - // }) - // } - // } } pub async fn suspense_scope(ctx: ScopeRef<'_>, f: impl Future) -> U { @@ -53,3 +73,58 @@ pub async fn suspense_scope(ctx: ScopeRef<'_>, f: impl Future) -> f.await } } + +pub async fn await_suspense( + ctx: ScopeRef<'_>, + f: impl Future>, +) -> View { + let state = SuspenseState { + async_count: create_rc_signal(0), + }; + ctx.provide_context(state.clone()); + let ret = f.await; + + let (sender, receiver) = oneshot::channel(); + let sender = ctx.create_ref(RefCell::new(Some(sender))); + + ctx.create_effect(move || { + if *state.async_count.get() == 0 { + if let Some(sender) = sender.take() { + let _ = sender.send(()); + } + } + }); + let _ = receiver.await; + ret +} + +#[cfg(all(test, feature = "ssr"))] +mod tests { + use sycamore_futures::provide_executor_scope; + + use crate::generic_node::render_to_string_await_suspense; + use crate::prelude::*; + use crate::suspense::Suspense; + + #[tokio::test] + async fn suspense() { + #[component] + async fn Comp(ctx: ScopeRef<'_>) -> View { + view! { ctx, "Hello Suspense!" } + } + + let view = provide_executor_scope(async { + render_to_string_await_suspense(|ctx| { + view! { ctx, + Suspense { + fallback: view! { ctx, "Loading..." }, + Comp {} + } + } + }) + .await + }) + .await; + assert_eq!(view, "Hello Suspense!"); + } +}