diff --git a/.github/workflows/everything.yml b/.github/workflows/everything.yml index 4e57ac1..7a4a0f1 100644 --- a/.github/workflows/everything.yml +++ b/.github/workflows/everything.yml @@ -93,6 +93,53 @@ jobs: - name: Run Tests run: cargo make tests + e2e: + name: E2E Tests + runs-on: ubuntu-latest + services: + httpbin: + image: kennethreitz/httpbin + ports: + - 8081:80 + steps: + - name: Checkout Project + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: wasm32-unknown-unknown + + - name: Restore Rust Cache + uses: Swatinem/rust-cache@v2 + with: + workspaces: | + . + e2e + + - name: Setup trunk + uses: jetli/trunk-action@v0.4.0 + with: + version: "latest" + + - uses: browser-actions/setup-geckodriver@latest + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build E2E fixture + run: cd e2e/query-app && trunk build + + - name: Start geckodriver + run: geckodriver --port 4444 & + + - name: Serve fixture app + run: python3 -m http.server 8000 -d e2e/query-app/dist & + + - name: Run E2E tests + run: cd e2e && cargo test -- --test-threads=1 + publish: name: Publish to crates.io runs-on: ubuntu-latest @@ -100,6 +147,7 @@ jobs: - lint - build - test + - e2e if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')) steps: - name: Checkout Project diff --git a/.gitignore b/.gitignore index 51cbeb5..8493fbb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ crates/*/target examples/*/target examples/*/dist +e2e/target +e2e/*/target +e2e/*/dist + book-build Cargo.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f9d704..3883c02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Removed async_trait requirement from query and mutation traits - Raise MSRV to 1.84. +### Other Changes + +- Fixed use_query and use_prepared_query stalling when the input changes. + ## Release 0.9.0 ### Breaking Changes diff --git a/Cargo.toml b/Cargo.toml index c0a551f..09e72a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ members = [ "examples/helmet-ssr", "examples/persist", ] +exclude = ["e2e"] resolver = "2" [profile.release] diff --git a/crates/bounce/src/lib.rs b/crates/bounce/src/lib.rs index 3d546ec..d5fb90c 100644 --- a/crates/bounce/src/lib.rs +++ b/crates/bounce/src/lib.rs @@ -88,7 +88,7 @@ pub use states::slice::Slice; /// A future-based notion that notifies states when it begins and finishes. /// -/// A future notion accepts a signle argument as input and returns an output. +/// A future notion accepts a single argument as input and returns an output. /// /// It can optionally accept a `states` parameter which has a type of [`BounceStates`] that can be /// used to access bounce states when being run. diff --git a/crates/bounce/src/query/use_prepared_query.rs b/crates/bounce/src/query/use_prepared_query.rs index 1db6539..bff4669 100644 --- a/crates/bounce/src/query/use_prepared_query.rs +++ b/crates/bounce/src/query/use_prepared_query.rs @@ -1,8 +1,10 @@ +use std::cell::RefCell; use std::rc::Rc; use serde::de::Deserialize; use serde::ser::Serialize; use wasm_bindgen::UnwrapThrowExt; +use yew::platform::pinned::oneshot; use yew::prelude::*; use yew::suspense::{Suspension, SuspensionResult}; @@ -10,7 +12,7 @@ use super::query_states::{ QuerySelector, QuerySlice, QuerySliceAction, QuerySliceValue, RunQuery, RunQueryInput, }; use super::traits::Query; -use super::use_query::{QueryState, UseQueryHandle}; +use super::use_query::{QueryMemoValue, QueryState, UseQueryHandle}; use crate::root_state::BounceRootState; use crate::states::future_notion::use_future_notion_runner; use crate::states::input_selector::use_input_selector_value; @@ -154,68 +156,107 @@ where .clone() }; - let value = use_memo(value_state.clone(), |v| match v.value { - Some(QuerySliceValue::Loading { .. }) | None => Err(Suspension::new()), - Some(QuerySliceValue::Completed { id, result: ref m }) => { - Ok((id, Rc::new(QueryState::Completed { result: m.clone() }))) - } - Some(QuerySliceValue::Outdated { id, result: ref m }) => Ok(( - id, - Rc::new(QueryState::Refreshing { - last_result: m.clone(), - }), - )), - }); + let has_prepared = prepared_value.is_some(); { let input = input.clone(); - let run_query = run_query.clone(); let dispatch_state = dispatch_state.clone(); - use_memo((), move |_| match prepared_value { - Some(m) => dispatch_state(QuerySliceAction::LoadPrepared { - id, - input, - result: m, - }), - None => run_query(RunQueryInput { - id, - input: input.clone(), - sender: Rc::default(), - is_refresh: false, - }), + use_memo((), move |_| { + if let Some(m) = prepared_value { + dispatch_state(QuerySliceAction::LoadPrepared { + id, + input, + result: m, + }); + } }); } + // Produce a Suspension or a ready value. When the value is not yet available, + // the query is initiated as part of constructing the Suspension (following + // the same pattern as Yew's use_future_with). + let value = use_memo((input.clone(), value_state.clone()), { + let run_query = run_query.clone(); + move |(input, value_state): &(Rc, Rc>)| { + match value_state.value { + None => { + if has_prepared { + // A prepared value was dispatched via LoadPrepared + // and will be available on the next render. + let (suspension, handle) = Suspension::new(); + QueryMemoValue::Suspended { + suspension, + _handle: Some(handle), + } + } else { + let (sender, receiver) = oneshot::channel(); + run_query(RunQueryInput { + id, + input: input.clone(), + sender: Rc::new(RefCell::new(Some(sender))), + is_refresh: false, + }); + QueryMemoValue::Suspended { + suspension: Suspension::from_future(async move { + let _ = receiver.await; + }), + _handle: None, + } + } + } + Some(QuerySliceValue::Loading { .. }) => { + let (suspension, handle) = Suspension::new(); + QueryMemoValue::Suspended { + suspension, + _handle: Some(handle), + } + } + Some(QuerySliceValue::Completed { id, ref result }) => QueryMemoValue::Ready { + id, + state: Rc::new(QueryState::Completed { + result: result.clone(), + }), + }, + Some(QuerySliceValue::Outdated { id, ref result }) => QueryMemoValue::Ready { + id, + state: Rc::new(QueryState::Refreshing { + last_result: result.clone(), + }), + }, + } + } + }); + { let input = input.clone(); let run_query = run_query.clone(); - use_effect_with( - (id, input, value_state.clone()), - move |(id, input, value_state)| { - if matches!(value_state.value, Some(QuerySliceValue::Outdated { .. })) { - run_query(RunQueryInput { - id: *id, - input: input.clone(), - sender: Rc::default(), - is_refresh: false, - }); - } + use_effect_with((id, input, value_state), move |(id, input, value_state)| { + if matches!(value_state.value, Some(QuerySliceValue::Outdated { .. })) { + run_query(RunQueryInput { + id: *id, + input: input.clone(), + sender: Rc::default(), + is_refresh: false, + }); + } - || {} - }, - ); + || {} + }); } - match value.as_ref().as_ref().cloned() { - Ok((state_id, state)) => Ok(UseQueryHandle { - state_id, - input, + match value.as_ref() { + QueryMemoValue::Ready { + id: state_id, state, + } => Ok(UseQueryHandle { + state: state.clone(), + state_id: *state_id, + input, dispatch_state, run_query, }), - Err((s, _)) => Err(s.clone()), + QueryMemoValue::Suspended { suspension, .. } => Err(suspension.clone()), } } diff --git a/crates/bounce/src/query/use_query.rs b/crates/bounce/src/query/use_query.rs index 326c3d5..6b6c827 100644 --- a/crates/bounce/src/query/use_query.rs +++ b/crates/bounce/src/query/use_query.rs @@ -5,7 +5,7 @@ use std::rc::Rc; use yew::platform::pinned::oneshot; use yew::prelude::*; -use yew::suspense::{Suspension, SuspensionResult}; +use yew::suspense::{Suspension, SuspensionHandle, SuspensionResult}; use super::query_states::{ QuerySelector, QuerySlice, QuerySliceAction, QuerySliceValue, RunQuery, RunQueryInput, @@ -16,6 +16,17 @@ use crate::states::input_selector::use_input_selector_value; use crate::states::slice::use_slice_dispatch; use crate::utils::Id; +pub(super) enum QueryMemoValue { + Suspended { + suspension: Suspension, + _handle: Option, + }, + Ready { + id: Id, + state: Rc>, + }, +} + /// Query State #[derive(Debug, PartialEq)] pub enum QueryState @@ -215,64 +226,78 @@ where let dispatch_state = use_slice_dispatch::>(); let run_query = use_future_notion_runner::>(); - let value = use_memo(value_state.clone(), |v| match v.value { - Some(QuerySliceValue::Loading { .. }) | None => Err(Suspension::new()), - Some(QuerySliceValue::Completed { id, result: ref m }) => { - Ok((id, Rc::new(QueryState::Completed { result: m.clone() }))) - } - Some(QuerySliceValue::Outdated { id, result: ref m }) => Ok(( - id, - Rc::new(QueryState::Refreshing { - last_result: m.clone(), - }), - )), - }); - - { - let input = input.clone(); + // Produce a Suspension or a ready value. When the value is not yet available, + // the query is initiated as part of constructing the Suspension (following + // the same pattern as Yew's use_future_with). + let value = use_memo((input.clone(), value_state.clone()), { let run_query = run_query.clone(); - - use_memo((), move |_| { - run_query(RunQueryInput { + move |(input, value_state): &(Rc, Rc>)| match value_state.value { + None => { + let (sender, receiver) = oneshot::channel(); + run_query(RunQueryInput { + id, + input: input.clone(), + sender: Rc::new(RefCell::new(Some(sender))), + is_refresh: false, + }); + QueryMemoValue::Suspended { + suspension: Suspension::from_future(async move { + let _ = receiver.await; + }), + _handle: None, + } + } + Some(QuerySliceValue::Loading { .. }) => { + let (suspension, handle) = Suspension::new(); + QueryMemoValue::Suspended { + suspension, + _handle: Some(handle), + } + } + Some(QuerySliceValue::Completed { id, ref result }) => QueryMemoValue::Ready { id, - input: input.clone(), - sender: Rc::default(), - is_refresh: false, - }); - }); - } + state: Rc::new(QueryState::Completed { + result: result.clone(), + }), + }, + Some(QuerySliceValue::Outdated { id, ref result }) => QueryMemoValue::Ready { + id, + state: Rc::new(QueryState::Refreshing { + last_result: result.clone(), + }), + }, + } + }); { let input = input.clone(); let run_query = run_query.clone(); - use_effect_with( - (id, input, value_state.clone()), - move |(id, input, value_state)| { - if matches!(value_state.value, Some(QuerySliceValue::Outdated { .. })) { - run_query(RunQueryInput { - id: *id, - input: input.clone(), - sender: Rc::default(), - is_refresh: false, - }); - } + use_effect_with((id, input, value_state), move |(id, input, value_state)| { + if matches!(value_state.value, Some(QuerySliceValue::Outdated { .. })) { + run_query(RunQueryInput { + id: *id, + input: input.clone(), + sender: Rc::default(), + is_refresh: false, + }); + } - || {} - }, - ); + || {} + }); } - value - .as_ref() - .as_ref() - .cloned() - .map(|(state_id, state)| UseQueryHandle { + match value.as_ref() { + QueryMemoValue::Ready { + id: state_id, state, - state_id, + } => Ok(UseQueryHandle { + state: state.clone(), + state_id: *state_id, input, dispatch_state, run_query, - }) - .map_err(|(s, _)| s.clone()) + }), + QueryMemoValue::Suspended { suspension, .. } => Err(suspension.clone()), + } } diff --git a/e2e/Cargo.toml b/e2e/Cargo.toml new file mode 100644 index 0000000..d229f54 --- /dev/null +++ b/e2e/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] + +[package] +name = "bounce-e2e" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +fantoccini = "0.22" +tokio = { version = "1", features = ["full"] } +serde_json = "1" diff --git a/e2e/query-app/Cargo.toml b/e2e/query-app/Cargo.toml new file mode 100644 index 0000000..57b0de3 --- /dev/null +++ b/e2e/query-app/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "bounce-e2e-query-app" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[dependencies] +yew = { version = "0.22", features = ["csr"] } +bounce = { path = "../../crates/bounce", features = ["query"] } +gloo-net = "0.6" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +web-sys = { version = "0.3", features = [ + "Window", + "Location", + "Performance", + "PerformanceEntry", + "PerformanceResourceTiming", +] } +wasm-bindgen = "0.2" diff --git a/e2e/query-app/index.html b/e2e/query-app/index.html new file mode 100644 index 0000000..a8ac5a0 --- /dev/null +++ b/e2e/query-app/index.html @@ -0,0 +1,9 @@ + + + + + Bounce E2E Query Test + + + + diff --git a/e2e/query-app/src/main.rs b/e2e/query-app/src/main.rs new file mode 100644 index 0000000..aaf768e --- /dev/null +++ b/e2e/query-app/src/main.rs @@ -0,0 +1,153 @@ +use std::convert::Infallible; +use std::rc::Rc; + +use bounce::prelude::*; +use bounce::query::{use_prepared_query, use_query, Query, QueryResult}; +use bounce::BounceRoot; +use gloo_net::http::Request; +use serde::{Deserialize, Serialize}; +use yew::prelude::*; + +#[derive(Debug, PartialEq)] +struct TestQuery(String); + +impl Query for TestQuery { + type Input = u32; + type Error = Infallible; + + async fn query(_states: &BounceStates, input: Rc) -> QueryResult { + let url = format!("http://localhost:8081/get?n={}", *input); + let resp = Request::get(&url).send().await.unwrap(); + let body = resp.text().await.unwrap(); + Ok(TestQuery(body).into()) + } +} + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +struct PreparedTestQuery(String); + +#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +struct PreparedQueryError(String); + +impl std::fmt::Display for PreparedQueryError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for PreparedQueryError {} + +impl Query for PreparedTestQuery { + type Input = u32; + type Error = PreparedQueryError; + + async fn query(_states: &BounceStates, input: Rc) -> QueryResult { + let url = format!("http://localhost:8081/get?n={}", *input); + let resp = Request::get(&url) + .send() + .await + .map_err(|e| PreparedQueryError(e.to_string()))?; + let body = resp + .text() + .await + .map_err(|e| PreparedQueryError(e.to_string()))?; + Ok(PreparedTestQuery(body).into()) + } +} + +#[derive(Properties, PartialEq)] +struct QueryDisplayProps { + input: u32, +} + +#[component] +fn QueryDisplay(props: &QueryDisplayProps) -> HtmlResult { + let result = use_query::(props.input.into())?; + Ok(html! { +
+ { format!("{:?}", result.as_ref().map(|q| &q.0)) } +
+ }) +} + +#[component] +fn PreparedDisplay(props: &QueryDisplayProps) -> HtmlResult { + let result = use_prepared_query::(props.input.into())?; + Ok(html! { +
+ { format!("{:?}", result.as_ref().map(|q| &q.0)) } +
+ }) +} + +#[component] +fn QuerySection() -> Html { + let counter = use_state(|| 0u32); + + html! { +
+ + {*counter} + + {"Loading..."}
}}> + + + + } +} + +#[component] +fn PreparedSection() -> Html { + let counter = use_state(|| 0u32); + + html! { +
+ + {*counter} + + {"Loading..."}
}}> + + + + } +} + +#[component] +fn App() -> Html { + let mode = use_state(|| "query".to_string()); + + { + let mode = mode.clone(); + use_effect_with((), move |_| { + let hash = web_sys::window() + .and_then(|w| w.location().hash().ok()) + .unwrap_or_default(); + if hash == "#prepared" { + mode.set("prepared".to_string()); + } + || {} + }); + } + + html! { + + if *mode == "query" { + + } else { + + } + + } +} + +fn main() { + yew::Renderer::::new().render(); +} diff --git a/e2e/tests/query.rs b/e2e/tests/query.rs new file mode 100644 index 0000000..635dd22 --- /dev/null +++ b/e2e/tests/query.rs @@ -0,0 +1,227 @@ +use std::time::Duration; + +use fantoccini::{ClientBuilder, Locator}; + +const APP_URL: &str = "http://localhost:8000"; +const WEBDRIVER_URL: &str = "http://localhost:4444"; + +async fn create_client() -> fantoccini::Client { + let mut caps = serde_json::Map::new(); + caps.insert( + "moz:firefoxOptions".to_string(), + serde_json::json!({"args": ["-headless"]}), + ); + ClientBuilder::native() + .capabilities(caps) + .connect(WEBDRIVER_URL) + .await + .expect("failed to connect to WebDriver") +} + +async fn get_fetch_count(client: &fantoccini::Client, url_fragment: &str) -> i64 { + let script = format!( + r#"return performance.getEntriesByType("resource") + .filter(e => e.initiatorType === "fetch" && e.name.includes("{}")) + .length;"#, + url_fragment + ); + client + .execute(&script, vec![]) + .await + .unwrap() + .as_i64() + .unwrap() +} + +async fn clear_performance(client: &fantoccini::Client) { + client + .execute("performance.clearResourceTimings();", vec![]) + .await + .unwrap(); +} + +async fn wait_for_result(client: &fantoccini::Client, selector: &str, data_input: u32) { + let css = format!("{}[data-input='{}']", selector, data_input); + client + .wait() + .at_most(Duration::from_secs(15)) + .for_element(Locator::Css(&css)) + .await + .unwrap_or_else(|_| panic!("Timed out waiting for {css} -- query likely stalled")); +} + +// --- use_query tests --- + +#[tokio::test] +async fn use_query_no_stall_on_input_change() { + let client = create_client().await; + client.goto(APP_URL).await.unwrap(); + + wait_for_result(&client, "#query-result", 0).await; + clear_performance(&client).await; + + client + .find(Locator::Css("#query-next")) + .await + .unwrap() + .click() + .await + .unwrap(); + wait_for_result(&client, "#query-result", 1).await; + + let count = get_fetch_count(&client, "/get?n=").await; + assert_eq!(count, 1, "Expected 1 fetch for new input, got {count}"); + + client.close().await.unwrap(); +} + +#[tokio::test] +async fn use_query_caches_seen_input() { + let client = create_client().await; + client.goto(APP_URL).await.unwrap(); + + wait_for_result(&client, "#query-result", 0).await; + + client + .find(Locator::Css("#query-next")) + .await + .unwrap() + .click() + .await + .unwrap(); + wait_for_result(&client, "#query-result", 1).await; + + clear_performance(&client).await; + + client + .find(Locator::Css("#query-prev")) + .await + .unwrap() + .click() + .await + .unwrap(); + wait_for_result(&client, "#query-result", 0).await; + + tokio::time::sleep(Duration::from_secs(1)).await; + + let count = get_fetch_count(&client, "/get?n=").await; + assert_eq!(count, 0, "Expected 0 fetches for cached input, got {count}"); + + client.close().await.unwrap(); +} + +#[tokio::test] +async fn use_query_no_duplicate_requests() { + let client = create_client().await; + client.goto(APP_URL).await.unwrap(); + + wait_for_result(&client, "#query-result", 0).await; + clear_performance(&client).await; + + client + .find(Locator::Css("#query-next")) + .await + .unwrap() + .click() + .await + .unwrap(); + wait_for_result(&client, "#query-result", 1).await; + + tokio::time::sleep(Duration::from_secs(1)).await; + + let count = get_fetch_count(&client, "/get?n=").await; + assert_eq!( + count, 1, + "Expected exactly 1 fetch (no duplicates), got {count}" + ); + + client.close().await.unwrap(); +} + +// --- use_prepared_query tests --- + +#[tokio::test] +async fn use_prepared_query_no_stall_on_input_change() { + let client = create_client().await; + client.goto(&format!("{APP_URL}/#prepared")).await.unwrap(); + + wait_for_result(&client, "#prepared-result", 0).await; + clear_performance(&client).await; + + client + .find(Locator::Css("#prepared-next")) + .await + .unwrap() + .click() + .await + .unwrap(); + wait_for_result(&client, "#prepared-result", 1).await; + + let count = get_fetch_count(&client, "/get?n=").await; + assert_eq!(count, 1, "Expected 1 fetch for new input, got {count}"); + + client.close().await.unwrap(); +} + +#[tokio::test] +async fn use_prepared_query_caches_seen_input() { + let client = create_client().await; + client.goto(&format!("{APP_URL}/#prepared")).await.unwrap(); + + wait_for_result(&client, "#prepared-result", 0).await; + + client + .find(Locator::Css("#prepared-next")) + .await + .unwrap() + .click() + .await + .unwrap(); + wait_for_result(&client, "#prepared-result", 1).await; + + clear_performance(&client).await; + + client + .find(Locator::Css("#prepared-prev")) + .await + .unwrap() + .click() + .await + .unwrap(); + wait_for_result(&client, "#prepared-result", 0).await; + + tokio::time::sleep(Duration::from_secs(1)).await; + + let count = get_fetch_count(&client, "/get?n=").await; + assert_eq!(count, 0, "Expected 0 fetches for cached input, got {count}"); + + client.close().await.unwrap(); +} + +#[tokio::test] +async fn use_prepared_query_no_duplicate_requests() { + let client = create_client().await; + client.goto(&format!("{APP_URL}/#prepared")).await.unwrap(); + + wait_for_result(&client, "#prepared-result", 0).await; + clear_performance(&client).await; + + client + .find(Locator::Css("#prepared-next")) + .await + .unwrap() + .click() + .await + .unwrap(); + wait_for_result(&client, "#prepared-result", 1).await; + + tokio::time::sleep(Duration::from_secs(1)).await; + + let count = get_fetch_count(&client, "/get?n=").await; + assert_eq!( + count, 1, + "Expected exactly 1 fetch (no duplicates), got {count}" + ); + + client.close().await.unwrap(); +}