use dioxus::prelude::*; use std::future::poll_fn; use std::task::Poll; async fn poll_three_times() { // Poll each task 3 times let mut count = 0; poll_fn(|cx| { println!("polling... {}", count); if count < 3 { count += 1; cx.waker().wake_by_ref(); Poll::Pending } else { Poll::Ready(()) } }) .await; } #[test] fn suspense_resolves() { // wait just a moment, not enough time for the boundary to resolve tokio::runtime::Builder::new_current_thread() .build() .unwrap() .block_on(async { let mut dom = VirtualDom::new(app); dom.rebuild(&mut dioxus_core::NoOpMutations); dom.wait_for_suspense().await; let out = dioxus_ssr::render(&dom); assert_eq!(out, "
Waiting for... child
"); }); } fn app() -> Element { rsx!( div { "Waiting for... " SuspenseBoundary { fallback: |_| rsx! { "fallback" }, suspended_child {} } } ) } fn suspended_child() -> Element { let mut val = use_signal(|| 0); // Tasks that are not suspended should never be polled spawn(async move { panic!("Non-suspended task was polled"); }); // Memos should still work like normal let memo = use_memo(move || val * 2); assert_eq!(memo, val * 2); if val() < 3 { let task = spawn(async move { poll_three_times().await; println!("waiting... {}", val); val += 1; }); suspend(task)?; } rsx!("child") } /// When switching from a suspense fallback to the real child, the state of that component must be kept #[test] fn suspense_keeps_state() { tokio::runtime::Builder::new_current_thread() .enable_time() .build() .unwrap() .block_on(async { let mut dom = VirtualDom::new(app); dom.rebuild(&mut dioxus_core::NoOpMutations); dom.render_suspense_immediate().await; let out = dioxus_ssr::render(&dom); assert_eq!(out, "fallback"); dom.wait_for_suspense().await; let out = dioxus_ssr::render(&dom); assert_eq!(out, "
child with future resolved
"); }); fn app() -> Element { rsx! { SuspenseBoundary { fallback: |_| rsx! { "fallback" }, Child {} } } } #[component] fn Child() -> Element { let mut future_resolved = use_signal(|| false); let task = use_hook(|| { spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(100)).await; future_resolved.set(true); }) }); if !future_resolved() { suspend(task)?; } println!("future resolved: {future_resolved:?}"); if future_resolved() { rsx! { div { "child with future resolved" } } } else { rsx! { div { "this should never be rendered" } } } } } /// spawn doesn't run in suspense #[test] fn suspense_does_not_poll_spawn() { tokio::runtime::Builder::new_current_thread() .enable_time() .build() .unwrap() .block_on(async { let mut dom = VirtualDom::new(app); dom.rebuild(&mut dioxus_core::NoOpMutations); dom.wait_for_suspense().await; let out = dioxus_ssr::render(&dom); assert_eq!(out, "
child with future resolved
"); }); fn app() -> Element { rsx! { SuspenseBoundary { fallback: |_| rsx! { "fallback" }, Child {} } } } #[component] fn Child() -> Element { let mut future_resolved = use_signal(|| false); // futures that are spawned, but not suspended should never be polled use_hook(|| { spawn(async move { panic!("Non-suspended task was polled"); }); }); let task = use_hook(|| { spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(100)).await; future_resolved.set(true); }) }); if !future_resolved() { suspend(task)?; } rsx! { div { "child with future resolved" } } } } /// suspended nodes are not mounted, so they should not run effects #[test] fn suspended_nodes_dont_trigger_effects() { tokio::runtime::Builder::new_current_thread() .enable_time() .build() .unwrap() .block_on(async { let mut dom = VirtualDom::new(app); dom.rebuild(&mut dioxus_core::NoOpMutations); let work = async move { loop { dom.wait_for_work().await; dom.render_immediate(&mut dioxus_core::NoOpMutations); } }; tokio::select! { _ = work => {}, _ = tokio::time::sleep(std::time::Duration::from_millis(100)) => {} } }); fn app() -> Element { rsx! { SuspenseBoundary { fallback: |_| rsx! { "fallback" }, Child {} } } } #[component] fn RerendersFrequently() -> Element { let mut count = use_signal(|| 0); use_future(move || async move { for _ in 0..100 { tokio::time::sleep(std::time::Duration::from_millis(10)).await; count.set(count() + 1); } }); rsx! { div { "rerenders frequently" } } } #[component] fn Child() -> Element { let mut future_resolved = use_signal(|| false); use_effect(|| panic!("effects should not run during suspense")); let task = use_hook(|| { spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(500)).await; future_resolved.set(true); }) }); if !future_resolved() { suspend(task)?; } rsx! { div { "child with future resolved" } } } } /// Make sure we keep any state of components when we switch from a resolved future to a suspended future #[test] fn resolved_to_suspended() { tracing_subscriber::fmt::SubscriberBuilder::default() .with_max_level(tracing::Level::INFO) .init(); static SUSPENDED: GlobalSignal = Signal::global(|| false); tokio::runtime::Builder::new_current_thread() .enable_time() .build() .unwrap() .block_on(async { let mut dom = VirtualDom::new(app); dom.rebuild(&mut dioxus_core::NoOpMutations); let out = dioxus_ssr::render(&dom); assert_eq!(out, "rendered 1 times"); dom.in_runtime(|| ScopeId::APP.in_runtime(|| *SUSPENDED.write() = true)); dom.render_suspense_immediate().await; let out = dioxus_ssr::render(&dom); assert_eq!(out, "fallback"); dom.wait_for_suspense().await; let out = dioxus_ssr::render(&dom); assert_eq!(out, "rendered 3 times"); }); fn app() -> Element { rsx! { SuspenseBoundary { fallback: |_| rsx! { "fallback" }, Child {} } } } #[component] fn Child() -> Element { let mut render_count = use_signal(|| 0); render_count += 1; let mut task = use_hook(|| CopyValue::new(None)); tracing::info!("render_count: {}", render_count.peek()); if SUSPENDED() { if task().is_none() { task.set(Some(spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(100)).await; tracing::info!("task finished"); *SUSPENDED.write() = false; }))); } suspend(task().unwrap())?; } rsx! { "rendered {render_count.peek()} times" } } } /// Make sure suspense tells the renderer that a suspense boundary was resolved #[test] fn suspense_tracks_resolved() { tokio::runtime::Builder::new_current_thread() .enable_time() .build() .unwrap() .block_on(async { let mut dom = VirtualDom::new(app); dom.rebuild(&mut dioxus_core::NoOpMutations); dom.render_suspense_immediate().await; dom.wait_for_suspense_work().await; assert_eq!( dom.render_suspense_immediate().await, vec![ScopeId(ScopeId::APP.0 + 1)] ); }); fn app() -> Element { rsx! { SuspenseBoundary { fallback: |_| rsx! { "fallback" }, Child {} } } } #[component] fn Child() -> Element { let mut resolved = use_signal(|| false); let task = use_hook(|| { spawn(async move { tokio::time::sleep(std::time::Duration::from_millis(100)).await; tracing::info!("task finished"); resolved.set(true); }) }); if resolved() { println!("suspense is resolved"); } else { println!("suspense is not resolved"); suspend(task)?; } rsx! { "child" } } }