diff --git a/examples/suspense_tests/Cargo.toml b/examples/suspense_tests/Cargo.toml index 7601c50e08..b7be8781cf 100644 --- a/examples/suspense_tests/Cargo.toml +++ b/examples/suspense_tests/Cargo.toml @@ -10,6 +10,7 @@ crate-type = ["cdylib", "rlib"] actix-files = { version = "0.6.6", optional = true } actix-web = { version = "4.8", optional = true, features = ["macros"] } console_error_panic_hook = "0.1.7" +js-sys = { version = "0.3.70", optional = true } leptos = { path = "../../leptos" } leptos_actix = { path = "../../integrations/actix", optional = true } leptos_router = { path = "../../router" } @@ -19,7 +20,10 @@ serde = "1.0" tokio = { version = "1.39", features = ["time", "rt"], optional = true } [features] -hydrate = ["leptos/hydrate"] +hydrate = [ + "dep:js-sys", + "leptos/hydrate", +] ssr = [ "dep:actix-files", "dep:actix-web", diff --git a/examples/suspense_tests/e2e/features/check_instrumented.feature b/examples/suspense_tests/e2e/features/check_instrumented.feature new file mode 100644 index 0000000000..95660da66b --- /dev/null +++ b/examples/suspense_tests/e2e/features/check_instrumented.feature @@ -0,0 +1,94 @@ +@check_instrumented +Feature: Instrumented Counters showing the expected values + + Scenario: I can fresh CSR instrumented counters + Given I see the app + When I access the instrumented counters via CSR + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 0 | + | item_overview | 0 | + | item_inspect | 0 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 0 | + | inspect_item_root | 0 | + | inspect_item_field | 0 | + + Scenario: I should see counter going up after viewing Item Listing + Given I see the app + When I select the following links + | Instrumented | + | Item Listing | + | Counters | + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 1 | + | item_overview | 0 | + | item_inspect | 0 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 1 | + | get_item | 0 | + | inspect_item_root | 0 | + | inspect_item_field | 0 | + + # the reload has happened in Item Listing, it follows a suspend + # will be called as hydration happens. + Scenario: Refreshing Item Listing should have only suspend counters + Given I see the app + When I access the instrumented counters via SSR + And I select the component Item Listing + And I reload the page + And I select the component Counters + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 1 | + | item_overview | 0 | + | item_inspect | 0 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 0 | + | inspect_item_root | 0 | + | inspect_item_field | 0 | + + Scenario: Reset CSR Counters work as expected. + Given I see the app + When I access the instrumented counters via SSR + And I select the component Item Listing + And I click on Reset CSR Counters + And I select the component Counters + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 0 | + | item_overview | 0 | + | item_inspect | 0 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 0 | + | inspect_item_root | 0 | + | inspect_item_field | 0 | + + Scenario: Standard usage of the instruments traversing down + Given I see the app + When I select the following links + | Instrumented | + | Item Listing | + | Item 2 | + | Inspect path3 | + | Inspect path3/field1 | + And I access the instrumented counters via CSR + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 1 | + | item_overview | 1 | + | item_inspect | 2 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 1 | + | get_item | 1 | + | inspect_item_root | 1 | + | inspect_item_field | 1 | diff --git a/examples/suspense_tests/e2e/features/check_instrumented_suspense_resource.feature b/examples/suspense_tests/e2e/features/check_instrumented_suspense_resource.feature new file mode 100644 index 0000000000..1ac66c6948 --- /dev/null +++ b/examples/suspense_tests/e2e/features/check_instrumented_suspense_resource.feature @@ -0,0 +1,195 @@ +@check_instrumented_suspense_resource +Feature: Using instrumented counters for real + Check that the suspend/suspense and the underlying resources are + called with the expected number of times for CSR rendering. + + Background: + + Given I see the app + And I select the mode Instrumented + + Scenario: Emulate steps 1 to 5 of issue #2961 + Given I select the link Target 3## + And I refresh the page + When I select the following links + | Item Listing | + | Target 4## | + And I go check the Counters + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 1 | + | item_overview | 2 | + | item_inspect | 0 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 2 | + | inspect_item_root | 0 | + | inspect_item_field | 0 | + + Scenario: Emulate step 6 of issue #2961 + Given I select the link Target 41# + And I refresh the page + When I select the following links + | Target 4## | + | Target 42# | + And I go check the Counters + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 0 | + | item_overview | 1 | + | item_inspect | 2 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 1 | + | inspect_item_root | 2 | + | inspect_item_field | 0 | + + Scenario: Emulate step 7 of issue #2961 + Given I select the link Target 42# + And I refresh the page + When I select the following links + | Target 4## | + | Target 42# | + | Target 41# | + And I go check the Counters + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 0 | + | item_overview | 1 | + | item_inspect | 3 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 1 | + | inspect_item_root | 3 | + | inspect_item_field | 0 | + + Scenario: Emulate step 8, "not trigger double fetch". + Given I select the link Target 3## + And I refresh the page + When I select the following links + | Item Listing | + | Target 4## | + | Target 41# | + And I go check the Counters + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 1 | + | item_overview | 2 | + | item_inspect | 1 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 2 | + | inspect_item_root | 1 | + | inspect_item_field | 0 | + + Scenario: Like above, for the "double fetch" which shouldn't happen + Given I select the link Target 3## + And I refresh the page + When I select the following links + | Item Listing | + | Target 4## | + | Target 41# | + | Target 3## | + And I go check the Counters + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 1 | + | item_overview | 3 | + | item_inspect | 1 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 3 | + | inspect_item_root | 1 | + | inspect_item_field | 0 | + + Scenario: Like above, but using 4## instead + Given I select the link Target 3## + And I refresh the page + When I select the following links + | Item Listing | + | Target 4## | + | Target 41# | + | Target 4## | + And I go check the Counters + Then I see the following counters under section + | Suspend Calls | | + | item_listing | 1 | + | item_overview | 3 | + | item_inspect | 1 | + And the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 2 | + | inspect_item_root | 1 | + | inspect_item_field | 0 | + + # Currently, get_item is invoked with `3` as the argument upon + # selection of `Item Listing` despite that `Item Listing` doesn't + # need `get_item` calls. Seems like it may be due to the system + # still reacting to the unmounting of the component that needed + # view that generated the original `Item 3` (hydrated from SSR). + # Tests above may also have this type of behavior, but is somewhat + # masked because the direction of going down and then back up, but + # if this behavior changes for the better (avoiding this spurious + # resource fetch) then the above tests may need updating to reflect + # the corrected behavior. Note the difference with the fully CSR + # scenario after this one + Scenario: Emulate part of step 8 of issue #2961 + Given I select the link Target 3## + And I refresh the page + When I select the link Item Listing + And I go check the Counters + Then I see the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 1 | + | inspect_item_root | 0 | + | inspect_item_field | 0 | + + # Instead of refreshing the page like above, CSR counters is reset + # instead to keep the starting counter conditions identical. + Scenario: Emulate above, instead of refresh page, reset csr counters + Given I select the link Target 3## + And I click on Reset CSR Counters + When I select the link Item Listing + And I go check the Counters + Then I see the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 0 | + | inspect_item_root | 0 | + | inspect_item_field | 0 | + + # Again, the following two sets demostrates resources making stale + # and redundant requests when hydrated, and not do so when under + # CSR. + Scenario: Start with hydration from Target 41# and go up + Given I select the link Target 41# + And I refresh the page + When I select the link Target 4## + And I select the link Item Listing + And I go check the Counters + Then I see the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 1 | + | inspect_item_root | 1 | + | inspect_item_field | 0 | + + Scenario: Start with hydration from Target 41# and go up + Given I select the link Target 41# + And I click on Reset CSR Counters + When I select the link Target 4## + And I select the link Item Listing + And I go check the Counters + Then I see the following counters under section + | Server Calls (CSR) | | + | list_items | 0 | + | get_item | 0 | + | inspect_item_root | 0 | + | inspect_item_field | 0 | diff --git a/examples/suspense_tests/e2e/tests/fixtures/action.rs b/examples/suspense_tests/e2e/tests/fixtures/action.rs index da6838aa9f..5ea70cae7a 100644 --- a/examples/suspense_tests/e2e/tests/fixtures/action.rs +++ b/examples/suspense_tests/e2e/tests/fixtures/action.rs @@ -37,3 +37,19 @@ pub async fn click_second_button(client: &Client) -> Result<()> { Ok(()) } + +pub async fn click_reset_counters_button(client: &Client) -> Result<()> { + let reset_counter = find::reset_counter(client).await?; + + reset_counter.click().await?; + + Ok(()) +} + +pub async fn click_reset_csr_counters_button(client: &Client) -> Result<()> { + let reset_counter = find::reset_csr_counter(client).await?; + + reset_counter.click().await?; + + Ok(()) +} diff --git a/examples/suspense_tests/e2e/tests/fixtures/check.rs b/examples/suspense_tests/e2e/tests/fixtures/check.rs index 3e47dc9bce..2b15e8091b 100644 --- a/examples/suspense_tests/e2e/tests/fixtures/check.rs +++ b/examples/suspense_tests/e2e/tests/fixtures/check.rs @@ -63,3 +63,21 @@ pub async fn second_count_is(client: &Client, expected: u32) -> Result<()> { Ok(()) } + +pub async fn instrumented_counts( + client: &Client, + expected: &[(&str, u32)], +) -> Result<()> { + let mut actual = Vec::<(&str, u32)>::new(); + + for (selector, _) in expected.iter() { + actual.push(( + selector, + find::instrumented_count(client, selector).await?, + )) + } + + assert_eq!(actual, expected); + + Ok(()) +} diff --git a/examples/suspense_tests/e2e/tests/fixtures/find.rs b/examples/suspense_tests/e2e/tests/fixtures/find.rs index f46301d8dd..c7761b5d9b 100644 --- a/examples/suspense_tests/e2e/tests/fixtures/find.rs +++ b/examples/suspense_tests/e2e/tests/fixtures/find.rs @@ -77,6 +77,43 @@ pub async fn second_button(client: &Client) -> Result { Ok(counter_button) } +pub async fn instrumented_count( + client: &Client, + selector: &str, +) -> Result { + let element = client + .wait() + .for_element(Locator::Id(selector)) + .await + .expect(format!("Element #{selector} not found.") + .as_str()); + let text = element.text().await?; + let count = text.parse::() + .expect(format!("Element #{selector} does not contain a number.") + .as_str()); + Ok(count) +} + +pub async fn reset_counter(client: &Client) -> Result { + let reset_button = client + .wait() + .for_element(Locator::Id("reset-counters")) + .await + .expect("Reset counter input not found"); + + Ok(reset_button) +} + +pub async fn reset_csr_counter(client: &Client) -> Result { + let reset_button = client + .wait() + .for_element(Locator::Id("reset-csr-counters")) + .await + .expect("Reset CSR counter input not found"); + + Ok(reset_button) +} + async fn component_message(client: &Client, id: &str) -> Result { let element = client.wait().for_element(Locator::Id(id)).await.expect( diff --git a/examples/suspense_tests/e2e/tests/fixtures/world/action_steps.rs b/examples/suspense_tests/e2e/tests/fixtures/world/action_steps.rs index 62859aa85e..b91279ef6f 100644 --- a/examples/suspense_tests/e2e/tests/fixtures/world/action_steps.rs +++ b/examples/suspense_tests/e2e/tests/fixtures/world/action_steps.rs @@ -1,6 +1,6 @@ use crate::fixtures::{action, world::AppWorld}; use anyhow::{Ok, Result}; -use cucumber::{given, when}; +use cucumber::{given, when, gherkin::Step}; #[given("I see the app")] #[when("I open the app")] @@ -12,19 +12,13 @@ async fn i_open_the_app(world: &mut AppWorld) -> Result<()> { } #[given(regex = r"^I select the mode (.*)$")] -async fn i_select_the_mode(world: &mut AppWorld, text: String) -> Result<()> { - let client = &world.client; - action::click_link(client, &text).await?; - - Ok(()) -} - #[given(regex = r"^I select the component (.*)$")] #[when(regex = "^I select the component (.*)$")] -async fn i_select_the_component( - world: &mut AppWorld, - text: String, -) -> Result<()> { +#[given(regex = "^I select the link (.*)$")] +#[when(regex = "^I select the link (.*)$")] +#[when(regex = "^I click on the link (.*)$")] +#[when(regex = "^I go check the (.*)$")] +async fn i_select_the_link(world: &mut AppWorld, text: String) -> Result<()> { let client = &world.client; action::click_link(client, &text).await?; @@ -59,3 +53,69 @@ async fn i_click_the_second_button_n_times( Ok(()) } + +#[given(regex = "^I (refresh|reload) the (browser|page)$")] +#[when(regex = "^I (refresh|reload) the (browser|page)$")] +async fn i_refresh_the_browser(world: &mut AppWorld) -> Result<()> { + let client = &world.client; + client.refresh().await?; + + Ok(()) +} + +#[when(expr = "I click on Reset Counters")] +async fn i_click_on_reset_counters(world: &mut AppWorld) -> Result<()> { + let client = &world.client; + action::click_reset_counters_button(client).await?; + + Ok(()) +} + +#[given(expr = "I click on Reset CSR Counters")] +#[when(expr = "I click on Reset CSR Counters")] +async fn i_click_on_reset_csr_counters(world: &mut AppWorld) -> Result<()> { + let client = &world.client; + action::click_reset_csr_counters_button(client).await?; + + Ok(()) +} + +#[when(expr = "I access the instrumented counters via SSR")] +async fn i_access_the_instrumented_counters_page_via_ssr( + world: &mut AppWorld, +) -> Result<()> { + let client = &world.client; + action::click_link(client, "Instrumented").await?; + action::click_link(client, "Counters").await?; + client.refresh().await?; + + Ok(()) +} + +#[when(expr = "I access the instrumented counters via CSR")] +async fn i_access_the_instrumented_counters_page_via_csr( + world: &mut AppWorld, +) -> Result<()> { + let client = &world.client; + action::click_link(client, "Instrumented").await?; + action::click_link(client, "Counters").await?; + + Ok(()) +} + +#[given(expr = "I select the following links")] +#[when(expr = "I select the following links")] +async fn i_select_the_following_links( + world: &mut AppWorld, + step: &Step, +) -> Result<()> { + let client = &world.client; + + if let Some(table) = step.table.as_ref() { + for row in table.rows.iter() { + action::click_link(client, &row[0]).await?; + } + } + + Ok(()) +} diff --git a/examples/suspense_tests/e2e/tests/fixtures/world/check_steps.rs b/examples/suspense_tests/e2e/tests/fixtures/world/check_steps.rs index 9eb03fd01b..2e44235677 100644 --- a/examples/suspense_tests/e2e/tests/fixtures/world/check_steps.rs +++ b/examples/suspense_tests/e2e/tests/fixtures/world/check_steps.rs @@ -1,6 +1,6 @@ use crate::fixtures::{check, world::AppWorld}; use anyhow::{Ok, Result}; -use cucumber::then; +use cucumber::{then, gherkin::Step}; #[then(regex = r"^I see the page title is (.*)$")] async fn i_see_the_page_title_is( @@ -79,3 +79,23 @@ async fn i_see_the_second_count_is( Ok(()) } + +#[then(expr = "I see the following counters under section")] +#[then(expr = "the following counters under section")] +async fn i_see_the_following_counters_under_section( + world: &mut AppWorld, + step: &Step, +) -> Result<()> { + // FIXME ideally check the mode; for now leave it because effort + let client = &world.client; + if let Some(table) = step.table.as_ref() { + let expected = table.rows + .iter() + .skip(1) + .map(|row| (row[0].as_str(), row[1].parse::().unwrap())) + .collect::>(); + check::instrumented_counts(client, &expected).await?; + } + + Ok(()) +} diff --git a/examples/suspense_tests/src/app.rs b/examples/suspense_tests/src/app.rs index 9ce1e3a7dd..daf96a4af8 100644 --- a/examples/suspense_tests/src/app.rs +++ b/examples/suspense_tests/src/app.rs @@ -1,3 +1,4 @@ +use crate::instrumented::InstrumentedRoutes; use leptos::prelude::*; use leptos_router::{ components::{Outlet, ParentRoute, Redirect, Route, Router, Routes, A}, @@ -41,6 +42,7 @@ pub fn App() -> impl IntoView { "Out-of-Order" "In-Order" "Async" + "Instrumented"
@@ -110,6 +112,7 @@ pub fn App() -> impl IntoView { +
diff --git a/examples/suspense_tests/src/instrumented.rs b/examples/suspense_tests/src/instrumented.rs new file mode 100644 index 0000000000..3653949f49 --- /dev/null +++ b/examples/suspense_tests/src/instrumented.rs @@ -0,0 +1,667 @@ +use leptos::prelude::*; +use leptos_router::{ + components::{ParentRoute, Route, A}, + hooks::use_params, + nested_router::Outlet, + params::Params, + MatchNestedRoutes, ParamSegment, SsrMode, StaticSegment, WildcardSegment, +}; + +#[cfg(feature = "ssr")] +pub(super) mod counter { + use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicU32, Ordering}, + LazyLock, Mutex, + }, + }; + + #[derive(Default)] + pub struct Counter(AtomicU32); + + impl Counter { + pub const fn new() -> Self { + Self(AtomicU32::new(0)) + } + + pub fn get(&self) -> u32 { + self.0.load(Ordering::SeqCst) + } + + pub fn inc(&self) -> u32 { + self.0.fetch_add(1, Ordering::SeqCst) + } + + pub fn reset(&self) { + self.0.store(0, Ordering::SeqCst); + } + } + + #[derive(Default)] + pub struct Counters { + pub list_items: Counter, + pub get_item: Counter, + pub inspect_item_root: Counter, + pub inspect_item_field: Counter, + } + + impl From<&mut Counters> for super::Counters { + fn from(counter: &mut Counters) -> Self { + Self { + get_item: counter.get_item.get(), + inspect_item_root: counter.inspect_item_root.get(), + inspect_item_field: counter.inspect_item_field.get(), + list_items: counter.list_items.get(), + } + } + } + + impl Counters { + pub fn reset(&self) { + self.get_item.reset(); + self.inspect_item_root.reset(); + self.inspect_item_field.reset(); + self.list_items.reset(); + } + } + + pub static COUNTERS: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct Item { + id: i64, + name: Option, + field: Option, +} + +#[server] +async fn list_items(ticket: u64) -> Result, ServerFnError> { + // emulate database query overhead + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + (*counter::COUNTERS) + .lock() + .expect("somehow panicked elsewhere") + .entry(ticket) + .or_default() + .list_items + .inc(); + Ok(vec![1, 2, 3, 4]) +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct GetItemResult(pub Item, pub Vec); + +#[server] +async fn get_item( + ticket: u64, + id: i64, +) -> Result { + // emulate database query overhead + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + (*counter::COUNTERS) + .lock() + .expect("somehow panicked elsewhere") + .entry(ticket) + .or_default() + .get_item + .inc(); + let name = None::; + let field = None::; + Ok(GetItemResult( + Item { id, name, field }, + ["path1", "path2", "path3"] + .into_iter() + .map(str::to_string) + .collect::>(), + )) +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct InspectItemResult(pub Item, pub String, pub Vec); + +#[server] +async fn inspect_item( + ticket: u64, + id: i64, + path: String, +) -> Result { + // emulate database query overhead + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + let mut split = path.split('/'); + let name = split.next().map(str::to_string); + let path = name + .clone() + .expect("name should have been defined at this point"); + let field = split + .next() + .and_then(|s| (!s.is_empty()).then(|| s.to_string())); + if field.is_none() { + (*counter::COUNTERS) + .lock() + .expect("somehow panicked elsewhere") + .entry(ticket) + .or_default() + .inspect_item_root + .inc(); + } else { + (*counter::COUNTERS) + .lock() + .expect("somehow panicked elsewhere") + .entry(ticket) + .or_default() + .inspect_item_field + .inc(); + } + Ok(InspectItemResult( + Item { id, name, field }, + path, + ["field1", "field2", "field3"] + .into_iter() + .map(str::to_string) + .collect::>(), + )) +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct Counters { + pub get_item: u32, + pub inspect_item_root: u32, + pub inspect_item_field: u32, + pub list_items: u32, +} + +#[server] +async fn get_counters(ticket: u64) -> Result { + Ok((*counter::COUNTERS) + .lock() + .expect("somehow panicked elsewhere") + .entry(ticket) + .or_default() + .into()) +} + +#[server(ResetCounters)] +async fn reset_counters(ticket: u64) -> Result<(), ServerFnError> { + (*counter::COUNTERS) + .lock() + .expect("somehow panicked elsewhere") + .entry(ticket) + .or_default() + .reset(); + // leptos::logging::log!("counters for ticket {ticket} have been reset"); + Ok(()) +} + +#[derive(Clone, Default)] +pub struct SuspenseCounters { + item_overview: u32, + item_inspect: u32, + item_listing: u32, +} + +#[component] +pub fn InstrumentedRoutes() -> impl MatchNestedRoutes + Clone { + // TODO should make this mode configurable via feature flag? + let ssr = SsrMode::Async; + view! { + + + + + + + + + + + + } + .into_inner() +} + +#[derive(Copy, Clone)] +pub struct Ticket(pub u64); + +#[derive(Copy, Clone)] +pub struct CSRTicket(pub u64); + +#[cfg(feature = "ssr")] +fn inst_ticket() -> u64 { + // SSR will always use 0 for the ticket + 0 +} + +#[cfg(not(feature = "ssr"))] +fn inst_ticket() -> u64 { + // CSR will use a random number for the ticket + (js_sys::Math::random() * ((u64::MAX - 1) as f64) + 1f64) as u64 +} + +#[component] +fn InstrumentedRoot() -> impl IntoView { + let counters = RwSignal::new(SuspenseCounters::default()); + provide_context(counters); + provide_field_nav_portlet_context(); + + // Generate a ID directly on this component. Rather than relying on + // additional server functions, doing it this way emulates more + // standard workflows better and to avoid having to add another + // thing to instrument/interfere with the typical use case. + // Downside is that randomness has a chance to conflict. + // + // Furthermore, this approach **will** result in unintuitive + // behavior when it isn't accounted for - specifically, the reason + // for this design is that when SSR it will guarantee usage of `0` + // as the ticket, while CSR it will be of some other value as the + // version it uses will be random. However, when trying to get back + // the counters associated with the ticket, rendering using SSR will + // always produce the SSR version and this quirk will need to be + // accounted for. + let ticket = inst_ticket(); + // leptos::logging::log!( + // "Ticket for this InstrumentedRoot instance: {ticket}" + // ); + provide_context(Ticket(ticket)); + + let csr_ticket = RwSignal::>::new(None); + + let reset_counters = ServerAction::::new(); + + Effect::new(move |_| { + let ticket = expect_context::().0; + csr_ticket.set(Some(CSRTicket(ticket))); + }); + + view! { +
+ + + + { + move || Suspend::new(async move { + let clear_suspense_counters = move |_| { + counters.update(|c| *c = SuspenseCounters::default()); + }; + csr_ticket.get().map(|ticket| { + let ticket = ticket.0; + view! { + + + + + } + }) + }) + } + +
+ } +} + +#[component] +fn InstrumentedTop() -> impl IntoView { + view! { +

"Instrumented Tests"

+

"These tests validates the number of invocations of server functions and suspenses per access."

+ + } +} + +#[component] +fn ItemRoot() -> impl IntoView { + let ticket = expect_context::().0; + provide_context(Resource::new_blocking( + move || (), + move |_| async move { list_items(ticket).await }, + )); + + view! { +

""

+ + } +} + +#[component] +fn ItemListing() -> impl IntoView { + let suspense_counters = expect_context::>(); + let resource = + expect_context::, ServerFnError>>>(); + let item_listing = move || { + Suspend::new(async move { + let result = resource.await.map(|items| items + .into_iter() + .map(move |item| + // FIXME seems like relative link isn't working, it is currently + // adding an extra `/` in artix; manually construct `a` instead. + //
  • "Item "{item}
  • + view! { +
  • "Item "{item}
  • + } + ) + .collect_view() + ); + suspense_counters.update_untracked(|c| c.item_listing += 1); + result + }) + }; + + view! { +

    ""

    +
      + + {item_listing} + +
    + } +} + +#[derive(Params, PartialEq, Clone, Debug)] +struct ItemTopParams { + id: Option, +} + +#[component] +fn ItemTop() -> impl IntoView { + let ticket = expect_context::().0; + let params = use_params::(); + // map result to an option as the focus isn't error rendering + provide_context(Resource::new_blocking( + move || params.get().map(|p| p.id), + move |id| async move { + match id { + Err(_) => None, + Ok(Some(id)) => get_item(ticket, id).await.ok(), + _ => None, + } + }, + )); + view! { +

    ""

    + + } +} + +#[component] +fn ItemOverview() -> impl IntoView { + let suspense_counters = expect_context::>(); + let resource = expect_context::>>(); + let item_view = move || { + Suspend::new(async move { + let result = resource.await.map(|GetItemResult(item, names)| view! { +

    {format!("Viewing {item:?}")}

    +
      { + names.into_iter() + .map(|name| { + // FIXME seems like relative link isn't working, it is currently + // adding an extra `/` in artix; manually construct `a` instead. + //
    • {format!("Inspect {name}")}
    • + let id = item.id; + view! { +
    • + "Inspect "{name.clone()} +
    • + } + }) + .collect_view() + }
    + }); + suspense_counters.update_untracked(|c| c.item_overview += 1); + result + }) + }; + + view! { +
    ""
    + + {item_view} + + } +} + +#[derive(Params, PartialEq, Clone, Debug)] +struct ItemInspectParams { + path: Option, +} + +#[component] +fn ItemInspect() -> impl IntoView { + let ticket = expect_context::().0; + let suspense_counters = expect_context::>(); + let params = use_params::(); + let res_overview = expect_context::>>(); + let res_inspect = Resource::new_blocking( + move || params.get().map(|p| p.path), + move |p| async move { + // leptos::logging::log!("res_inspect: res_overview.await"); + let overview = res_overview.await; + // leptos::logging::log!("res_inspect: resolved res_overview.await"); + // let result = + match (overview, p) { + (Some(item), Ok(Some(path))) => { + // leptos::logging::log!("res_inspect: inspect_item().await"); + inspect_item(ticket, item.0.id, path.clone()).await.ok() + } + _ => None, + } + // ; + // leptos::logging::log!("res_inspect: resolved inspect_item().await"); + // result + }, + ); + on_cleanup(|| { + if let Some(c) = use_context::>>() { + c.set(None); + } + }); + let inspect_view = move || { + // leptos::logging::log!("inspect_view closure invoked"); + Suspend::new(async move { + // leptos::logging::log!("inspect_view Suspend::new() called"); + let result = res_inspect.await.map(|InspectItemResult(item, name, fields)| { + // leptos::logging::log!("inspect_view res_inspect awaited"); + let id = item.id; + expect_context::>>().set(Some( + fields.iter() + .map(|field| FieldNavItem { + href: format!("/instrumented/item/{id}/{name}/{field}"), + text: field.to_string(), + }) + .collect::>() + .into() + )); + view! { +

    {format!("Inspecting {item:?}")}

    +
      { + fields.iter() + .map(|field| { + // FIXME seems like relative link to root for a wildcard isn't + // working as expected, so manually construct `a` instead. + // let text = format!("Inspect {name}/{field}"); + // view! { + //
    • {text}
    • + // } + view! { +
    • { + format!("Inspect {name}/{field}") + }
    • + } + }) + .collect_view() + }
    + } + }); + suspense_counters.update_untracked(|c| c.item_inspect += 1); + // leptos::logging::log!( + // "returning result, result.is_some() = {}, count = {}", + // result.is_some(), + // suspense_counters.get().item_inspect, + // ); + result + }) + }; + + view! { +
    ""
    + + {inspect_view} + + } +} + +#[component] +fn ShowCounters() -> impl IntoView { + // There is _weirdness_ in this view. The `Server Calls` counters + // will be acquired via the expected mode and be rendered as such. + // + // However, upon `Reset Counters`, the mode from which the reset + // was issued will result in the rendering be reflected as such, so + // if the intial state was SSR, resetting under CSR will result in + // the CSR counters be rendered after. However for the intents and + // purpose for the testing only the CSR is cared for. + // + // At the end of the day, it is possible to have both these be + // separated out, but for the purpose of this test the focus is not + // on the SSR side of things (at least until further regression is + // discovered that affects SSR directly). + let ticket = expect_context::().0; + let suspense_counters = expect_context::>(); + let reset_counters = ServerAction::::new(); + let res_counter = Resource::new( + move || reset_counters.version().get(), + move |_| async move { + ( + get_counters(ticket).await, + if ticket == 0 { "SSR" } else { "CSR" }.to_string(), + ticket, + ) + }, + ); + let counter_view = move || { + Suspend::new(async move { + // ensure current mode and ticket are both updated + let (counters, mode, ticket) = res_counter.await; + counters.map(|counters| { + let clear_suspense_counters = move |_| { + suspense_counters.update(|c| { + // leptos::logging::log!("resetting suspense counters"); + *c = SuspenseCounters::default(); + }); + }; + view! { +

    "Server Calls ("{mode}")"

    +
    +
    "list_items"
    +
    {counters.list_items}
    +
    "get_item"
    +
    {counters.get_item}
    +
    "inspect_item_root"
    +
    {counters.inspect_item_root}
    +
    "inspect_item_field"
    +
    {counters.inspect_item_field}
    +
    + + + + + } + }) + }) + }; + + view! { +

    "Counters"

    + +

    "Suspend Calls"

    + {move || suspense_counters.with(|c| view! { +
    +
    "item_listing"
    +
    {c.item_listing}
    +
    "item_overview"
    +
    {c.item_overview}
    +
    "item_inspect"
    +
    {c.item_inspect}
    +
    + })} + + + {counter_view} + + } +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +pub struct FieldNavItem { + pub href: String, + pub text: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug, PartialEq)] +pub struct FieldNavCtx(pub Option>); + +impl From> for FieldNavCtx { + fn from(item: Vec) -> Self { + Self(Some(item)) + } +} + +#[component] +pub fn FieldNavPortlet() -> impl IntoView { + let ctx = expect_context::>>(); + move || { + let ctx = ctx.get(); + ctx.map(|ctx| { + view! { +
    + "FieldNavPortlet:" + +
    + } + }) + } +} + +pub fn provide_field_nav_portlet_context() { + // wrapping the Ctx in an Option allows better ergonomics whenever it isn't needed + let (ctx, set_ctx) = signal(None::); + provide_context(ctx); + provide_context(set_ctx); +} diff --git a/examples/suspense_tests/src/lib.rs b/examples/suspense_tests/src/lib.rs index f2f0be2fa6..fed38dd0ba 100644 --- a/examples/suspense_tests/src/lib.rs +++ b/examples/suspense_tests/src/lib.rs @@ -1,4 +1,5 @@ pub mod app; +mod instrumented; #[cfg(feature = "hydrate")] #[wasm_bindgen::prelude::wasm_bindgen]