diff --git a/examples/islands/Cargo.toml b/examples/islands/Cargo.toml new file mode 100644 index 000000000..94786a001 --- /dev/null +++ b/examples/islands/Cargo.toml @@ -0,0 +1,94 @@ +[package] +name = "todo_app_sqlite_axum" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +console_log = "1.0" +console_error_panic_hook = "0.1" +futures = "0.3" +http = "1.0" +leptos = { path = "../../leptos", features = ["tracing", "experimental-islands"] } +server_fn = { path = "../../server_fn", features = ["serde-lite"] } +leptos_axum = { path = "../../integrations/axum", optional = true } +log = "0.4" +simple_logger = "4.0" +serde = { version = "1", features = ["derive"] } +axum = { version = "0.7", optional = true } +tower = { version = "0.4", optional = true } +tower-http = { version = "0.5", features = ["fs"], optional = true } +tokio = { version = "1", features = ["full"], optional = true } +sqlx = { version = "0.7", features = [ + "runtime-tokio-rustls", + "sqlite", +], optional = true } +thiserror = "1.0" +wasm-bindgen = "0.2" + +tracing = "0.1" +tracing-subscriber = "0.3" +tracing-subscriber-wasm = "0.1" + +[features] +hydrate = ["leptos/hydrate"] +ssr = [ + "dep:axum", + "dep:tower", + "dep:tower-http", + "dep:tokio", + "dep:sqlx", + "leptos/ssr", + "dep:leptos_axum", +] + +[package.metadata.cargo-all-features] +denylist = ["axum", "tower", "tower-http", "tokio", "sqlx", "leptos_axum"] +skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"]] + +[package.metadata.leptos] +# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name +output-name = "todo_app_sqlite_axum" +# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup. +site-root = "target/site" +# The site-root relative folder where all compiled output (JS, WASM and CSS) is written +# Defaults to pkg +site-pkg-dir = "pkg" +# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to //app.css +style-file = "./style.css" +# [Optional] Files in the asset-dir will be copied to the site-root directory +assets-dir = "public" +# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup. +site-addr = "127.0.0.1:3000" +# The port to use for automatic reload monitoring +reload-port = 3001 +# [Optional] Command to use when running end2end tests. It will run in the end2end dir. +end2end-cmd = "cargo make test-ui" +end2end-dir = "e2e" +# The browserlist query used for optimizing the CSS. +browserquery = "defaults" +# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head +watch = false +# The environment Leptos will run in, usually either "DEV" or "PROD" +env = "DEV" +# The features to use when compiling the bin target +# +# Optional. Can be over-ridden with the command line parameter --bin-features +bin-features = ["ssr"] + +# If the --no-default-features flag should be used when compiling the bin target +# +# Optional. Defaults to false. +bin-default-features = false + +# The features to use when compiling the lib target +# +# Optional. Can be over-ridden with the command line parameter --lib-features +lib-features = ["hydrate"] + +# If the --no-default-features flag should be used when compiling the lib target +# +# Optional. Defaults to false. +lib-default-features = false diff --git a/examples/islands/LICENSE b/examples/islands/LICENSE new file mode 100644 index 000000000..77d5625cb --- /dev/null +++ b/examples/islands/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Greg Johnston + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/examples/islands/Makefile.toml b/examples/islands/Makefile.toml new file mode 100644 index 000000000..d5fd86f31 --- /dev/null +++ b/examples/islands/Makefile.toml @@ -0,0 +1,12 @@ +extend = [ + { path = "../cargo-make/main.toml" }, + { path = "../cargo-make/cargo-leptos-webdriver-test.toml" }, +] + +[env] +CLIENT_PROCESS_NAME = "todo_app_sqlite_axum" + +[tasks.test-ui] +cwd = "./e2e" +command = "cargo" +args = ["make", "test-ui", "${@}"] diff --git a/examples/islands/README.md b/examples/islands/README.md new file mode 100644 index 000000000..b4f1639c4 --- /dev/null +++ b/examples/islands/README.md @@ -0,0 +1,19 @@ +# Leptos Todo App Sqlite with Axum + +This example creates a basic todo app with an Axum backend that uses Leptos' server functions to call sqlx from the client and seamlessly run it on the server. + +## Getting Started + +See the [Examples README](../README.md) for setup and run instructions. + +## E2E Testing + +See the [E2E README](./e2e/README.md) for more information about the testing strategy. + +## Rendering + +See the [SSR Notes](../SSR_NOTES.md) for more information about Server Side Rendering. + +## Quick Start + +Run `cargo leptos watch` to run this example. diff --git a/examples/islands/e2e/Cargo.toml b/examples/islands/e2e/Cargo.toml new file mode 100644 index 000000000..722428675 --- /dev/null +++ b/examples/islands/e2e/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "todo_app_sqlite_axum_e2e" +version = "0.1.0" +edition = "2021" + +[dev-dependencies] +anyhow = "1.0.72" +async-trait = "0.1.72" +cucumber = "0.19.1" +fantoccini = "0.19.3" +pretty_assertions = "1.4.0" +serde_json = "1.0.104" +tokio = { version = "1.29.1", features = ["macros", "rt-multi-thread", "time"] } +url = "2.4.0" + +[[test]] +name = "app_suite" +harness = false # Allow Cucumber to print output instead of libtest diff --git a/examples/islands/e2e/Makefile.toml b/examples/islands/e2e/Makefile.toml new file mode 100644 index 000000000..cd76be24d --- /dev/null +++ b/examples/islands/e2e/Makefile.toml @@ -0,0 +1,20 @@ +extend = { path = "../../cargo-make/main.toml" } + +[tasks.test] +env = { RUN_AUTOMATICALLY = false } +condition = { env_true = ["RUN_AUTOMATICALLY"] } + +[tasks.ci] + +[tasks.test-ui] +command = "cargo" +args = [ + "test", + "--test", + "app_suite", + "--", + "--retry", + "2", + "--fail-fast", + "${@}", +] diff --git a/examples/islands/e2e/README.md b/examples/islands/e2e/README.md new file mode 100644 index 000000000..026f2befd --- /dev/null +++ b/examples/islands/e2e/README.md @@ -0,0 +1,34 @@ +# E2E Testing + +This example demonstrates e2e testing with Rust using executable requirements. + +## Testing Stack + +| | Role | Description | +|---|---|---| +| [Cucumber](https://github.com/cucumber-rs/cucumber/tree/main) | Test Runner | Run [Gherkin](https://cucumber.io/docs/gherkin/reference/) specifications as Rust tests | +| [Fantoccini](https://github.com/jonhoo/fantoccini/tree/main) | Browser Client | Interact with web pages through WebDriver | +| [Cargo Leptos ](https://github.com/leptos-rs/cargo-leptos) | Build Tool | Compile example and start the server and end-2-end tests | +| [chromedriver](https://chromedriver.chromium.org/downloads) | WebDriver | Provide WebDriver for Chrome + +## Testing Organization + +Testing is organized around what a user can do and see/not see. Test scenarios are grouped by the **user action** and the **object** of that action. This makes it easier to locate and reason about requirements. + +Here is a brief overview of how things fit together. + +```bash +features +└── {action}_{object}.feature # Specify test scenarios +tests +├── fixtures +│ ├── action.rs # Perform a user action (click, type, etc.) +│ ├── check.rs # Assert what a user can see/not see +│ ├── find.rs # Query page elements +│ ├── mod.rs +│ └── world +│ ├── action_steps.rs # Map Gherkin steps to user actions +│ ├── check_steps.rs # Map Gherkin steps to user expectations +│ └── mod.rs +└── app_suite.rs # Test main +``` diff --git a/examples/islands/e2e/features/add_todo.feature b/examples/islands/e2e/features/add_todo.feature new file mode 100644 index 000000000..b2a1331ca --- /dev/null +++ b/examples/islands/e2e/features/add_todo.feature @@ -0,0 +1,16 @@ +@add_todo +Feature: Add Todo + + Background: + Given I see the app + + @add_todo-see + Scenario: Should see the todo + Given I set the todo as Buy Bread + When I click the Add button + Then I see the todo named Buy Bread + + @add_todo-style + Scenario: Should see the pending todo + When I add a todo as Buy Oranges + Then I see the pending todo diff --git a/examples/islands/e2e/features/delete_todo.feature b/examples/islands/e2e/features/delete_todo.feature new file mode 100644 index 000000000..3c1e743d2 --- /dev/null +++ b/examples/islands/e2e/features/delete_todo.feature @@ -0,0 +1,18 @@ +@delete_todo +Feature: Delete Todo + + Background: + Given I see the app + + @serial + @delete_todo-remove + Scenario: Should not see the deleted todo + Given I add a todo as Buy Yogurt + When I delete the todo named Buy Yogurt + Then I do not see the todo named Buy Yogurt + + @serial + @delete_todo-message + Scenario: Should see the empty list message + When I empty the todo list + Then I see the empty list message is No tasks were found. \ No newline at end of file diff --git a/examples/islands/e2e/features/open_app.feature b/examples/islands/e2e/features/open_app.feature new file mode 100644 index 000000000..f4b4e3952 --- /dev/null +++ b/examples/islands/e2e/features/open_app.feature @@ -0,0 +1,12 @@ +@open_app +Feature: Open App + + @open_app-title + Scenario: Should see the home page title + When I open the app + Then I see the page title is My Tasks + + @open_app-label + Scenario: Should see the input label + When I open the app + Then I see the label of the input is Add a Todo \ No newline at end of file diff --git a/examples/islands/e2e/tests/app_suite.rs b/examples/islands/e2e/tests/app_suite.rs new file mode 100644 index 000000000..5c56b6aca --- /dev/null +++ b/examples/islands/e2e/tests/app_suite.rs @@ -0,0 +1,14 @@ +mod fixtures; + +use anyhow::Result; +use cucumber::World; +use fixtures::world::AppWorld; + +#[tokio::main] +async fn main() -> Result<()> { + AppWorld::cucumber() + .fail_on_skipped() + .run_and_exit("./features") + .await; + Ok(()) +} diff --git a/examples/islands/e2e/tests/fixtures/action.rs b/examples/islands/e2e/tests/fixtures/action.rs new file mode 100644 index 000000000..79b5c685e --- /dev/null +++ b/examples/islands/e2e/tests/fixtures/action.rs @@ -0,0 +1,60 @@ +use super::{find, world::HOST}; +use anyhow::Result; +use fantoccini::Client; +use std::result::Result::Ok; +use tokio::{self, time}; + +pub async fn goto_path(client: &Client, path: &str) -> Result<()> { + let url = format!("{}{}", HOST, path); + client.goto(&url).await?; + + Ok(()) +} + +pub async fn add_todo(client: &Client, text: &str) -> Result<()> { + fill_todo(client, text).await?; + click_add_button(client).await?; + Ok(()) +} + +pub async fn fill_todo(client: &Client, text: &str) -> Result<()> { + let textbox = find::todo_input(client).await; + textbox.send_keys(text).await?; + + Ok(()) +} + +pub async fn click_add_button(client: &Client) -> Result<()> { + let add_button = find::add_button(client).await; + add_button.click().await?; + + Ok(()) +} + +pub async fn empty_todo_list(client: &Client) -> Result<()> { + let todos = find::todos(client).await; + + for _todo in todos { + let _ = delete_first_todo(client).await?; + } + + Ok(()) +} + +pub async fn delete_first_todo(client: &Client) -> Result<()> { + if let Some(element) = find::first_delete_button(client).await { + element.click().await.expect("Failed to delete todo"); + time::sleep(time::Duration::from_millis(250)).await; + } + + Ok(()) +} + +pub async fn delete_todo(client: &Client, text: &str) -> Result<()> { + if let Some(element) = find::delete_button(client, text).await { + element.click().await?; + time::sleep(time::Duration::from_millis(250)).await; + } + + Ok(()) +} diff --git a/examples/islands/e2e/tests/fixtures/check.rs b/examples/islands/e2e/tests/fixtures/check.rs new file mode 100644 index 000000000..f43629b95 --- /dev/null +++ b/examples/islands/e2e/tests/fixtures/check.rs @@ -0,0 +1,57 @@ +use super::find; +use anyhow::{Ok, Result}; +use fantoccini::{Client, Locator}; +use pretty_assertions::assert_eq; + +pub async fn text_on_element( + client: &Client, + selector: &str, + expected_text: &str, +) -> Result<()> { + let element = client + .wait() + .for_element(Locator::Css(selector)) + .await + .expect( + format!("Element not found by Css selector `{}`", selector) + .as_str(), + ); + + let actual = element.text().await?; + assert_eq!(&actual, expected_text); + + Ok(()) +} + +pub async fn todo_present( + client: &Client, + text: &str, + expected: bool, +) -> Result<()> { + let todo_present = is_todo_present(client, text).await; + + assert_eq!(todo_present, expected); + + Ok(()) +} + +async fn is_todo_present(client: &Client, text: &str) -> bool { + let todos = find::todos(client).await; + + for todo in todos { + let todo_title = todo.text().await.expect("Todo title not found"); + if todo_title == text { + return true; + } + } + + false +} + +pub async fn todo_is_pending(client: &Client) -> Result<()> { + if let None = find::pending_todo(client).await { + assert!(false, "Pending todo not found"); + } + + Ok(()) +} diff --git a/examples/islands/e2e/tests/fixtures/find.rs b/examples/islands/e2e/tests/fixtures/find.rs new file mode 100644 index 000000000..228fce6a2 --- /dev/null +++ b/examples/islands/e2e/tests/fixtures/find.rs @@ -0,0 +1,63 @@ +use fantoccini::{elements::Element, Client, Locator}; + +pub async fn todo_input(client: &Client) -> Element { + let textbox = client + .wait() + .for_element(Locator::Css("input[name='title")) + .await + .expect("Todo textbox not found"); + + textbox +} + +pub async fn add_button(client: &Client) -> Element { + let button = client + .wait() + .for_element(Locator::Css("input[value='Add']")) + .await + .expect(""); + + button +} + +pub async fn first_delete_button(client: &Client) -> Option { + if let Ok(element) = client + .wait() + .for_element(Locator::Css("li:first-child input[value='X']")) + .await + { + return Some(element); + } + + None +} + +pub async fn delete_button(client: &Client, text: &str) -> Option { + let selector = format!("//*[text()='{text}']//input[@value='X']"); + if let Ok(element) = + client.wait().for_element(Locator::XPath(&selector)).await + { + return Some(element); + } + + None +} + +pub async fn pending_todo(client: &Client) -> Option { + if let Ok(element) = + client.wait().for_element(Locator::Css(".pending")).await + { + return Some(element); + } + + None +} + +pub async fn todos(client: &Client) -> Vec { + let todos = client + .find_all(Locator::Css("li")) + .await + .expect("Todo List not found"); + + todos +} diff --git a/examples/islands/e2e/tests/fixtures/mod.rs b/examples/islands/e2e/tests/fixtures/mod.rs new file mode 100644 index 000000000..72b1bd65e --- /dev/null +++ b/examples/islands/e2e/tests/fixtures/mod.rs @@ -0,0 +1,4 @@ +pub mod action; +pub mod check; +pub mod find; +pub mod world; diff --git a/examples/islands/e2e/tests/fixtures/world/action_steps.rs b/examples/islands/e2e/tests/fixtures/world/action_steps.rs new file mode 100644 index 000000000..5c4e062db --- /dev/null +++ b/examples/islands/e2e/tests/fixtures/world/action_steps.rs @@ -0,0 +1,57 @@ +use crate::fixtures::{action, world::AppWorld}; +use anyhow::{Ok, Result}; +use cucumber::{given, when}; + +#[given("I see the app")] +#[when("I open the app")] +async fn i_open_the_app(world: &mut AppWorld) -> Result<()> { + let client = &world.client; + action::goto_path(client, "").await?; + + Ok(()) +} + +#[given(regex = "^I add a todo as (.*)$")] +#[when(regex = "^I add a todo as (.*)$")] +async fn i_add_a_todo_titled(world: &mut AppWorld, text: String) -> Result<()> { + let client = &world.client; + action::add_todo(client, text.as_str()).await?; + + Ok(()) +} + +#[given(regex = "^I set the todo as (.*)$")] +async fn i_set_the_todo_as(world: &mut AppWorld, text: String) -> Result<()> { + let client = &world.client; + action::fill_todo(client, &text).await?; + + Ok(()) +} + +#[when(regex = "I click the Add button$")] +async fn i_click_the_button(world: &mut AppWorld) -> Result<()> { + let client = &world.client; + action::click_add_button(client).await?; + + Ok(()) +} + +#[when(regex = "^I delete the todo named (.*)$")] +async fn i_delete_the_todo_named( + world: &mut AppWorld, + text: String, +) -> Result<()> { + let client = &world.client; + action::delete_todo(client, text.as_str()).await?; + + Ok(()) +} + +#[given("the todo list is empty")] +#[when("I empty the todo list")] +async fn i_empty_the_todo_list(world: &mut AppWorld) -> Result<()> { + let client = &world.client; + action::empty_todo_list(client).await?; + + Ok(()) +} diff --git a/examples/islands/e2e/tests/fixtures/world/check_steps.rs b/examples/islands/e2e/tests/fixtures/world/check_steps.rs new file mode 100644 index 000000000..3e51215db --- /dev/null +++ b/examples/islands/e2e/tests/fixtures/world/check_steps.rs @@ -0,0 +1,67 @@ +use crate::fixtures::{check, world::AppWorld}; +use anyhow::{Ok, Result}; +use cucumber::then; + +#[then(regex = "^I see the page title is (.*)$")] +async fn i_see_the_page_title_is( + world: &mut AppWorld, + text: String, +) -> Result<()> { + let client = &world.client; + check::text_on_element(client, "h1", &text).await?; + + Ok(()) +} + +#[then(regex = "^I see the label of the input is (.*)$")] +async fn i_see_the_label_of_the_input_is( + world: &mut AppWorld, + text: String, +) -> Result<()> { + let client = &world.client; + check::text_on_element(client, "label", &text).await?; + + Ok(()) +} + +#[then(regex = "^I see the todo named (.*)$")] +async fn i_see_the_todo_is_present( + world: &mut AppWorld, + text: String, +) -> Result<()> { + let client = &world.client; + check::todo_present(client, text.as_str(), true).await?; + + Ok(()) +} + +#[then("I see the pending todo")] +async fn i_see_the_pending_todo(world: &mut AppWorld) -> Result<()> { + let client = &world.client; + + check::todo_is_pending(client).await?; + + Ok(()) +} + +#[then(regex = "^I see the empty list message is (.*)$")] +async fn i_see_the_empty_list_message_is( + world: &mut AppWorld, + text: String, +) -> Result<()> { + let client = &world.client; + check::text_on_element(client, "ul p", &text).await?; + + Ok(()) +} + +#[then(regex = "^I do not see the todo named (.*)$")] +async fn i_do_not_see_the_todo_is_present( + world: &mut AppWorld, + text: String, +) -> Result<()> { + let client = &world.client; + check::todo_present(client, text.as_str(), false).await?; + + Ok(()) +} diff --git a/examples/islands/e2e/tests/fixtures/world/mod.rs b/examples/islands/e2e/tests/fixtures/world/mod.rs new file mode 100644 index 000000000..c25a92570 --- /dev/null +++ b/examples/islands/e2e/tests/fixtures/world/mod.rs @@ -0,0 +1,39 @@ +pub mod action_steps; +pub mod check_steps; + +use anyhow::Result; +use cucumber::World; +use fantoccini::{ + error::NewSessionError, wd::Capabilities, Client, ClientBuilder, +}; + +pub const HOST: &str = "http://127.0.0.1:3000"; + +#[derive(Debug, World)] +#[world(init = Self::new)] +pub struct AppWorld { + pub client: Client, +} + +impl AppWorld { + async fn new() -> Result { + let webdriver_client = build_client().await?; + + Ok(Self { + client: webdriver_client, + }) + } +} + +async fn build_client() -> Result { + let mut cap = Capabilities::new(); + let arg = serde_json::from_str("{\"args\": [\"-headless\"]}").unwrap(); + cap.insert("goog:chromeOptions".to_string(), arg); + + let client = ClientBuilder::native() + .capabilities(cap) + .connect("http://localhost:4444") + .await?; + + Ok(client) +} diff --git a/examples/islands/public/favicon.ico b/examples/islands/public/favicon.ico new file mode 100644 index 000000000..2ba8527cb Binary files /dev/null and b/examples/islands/public/favicon.ico differ diff --git a/examples/islands/rust-toolchain.toml b/examples/islands/rust-toolchain.toml new file mode 100644 index 000000000..ff2a4ff10 --- /dev/null +++ b/examples/islands/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" # test change diff --git a/examples/islands/src/app.rs b/examples/islands/src/app.rs new file mode 100644 index 000000000..65d99ba9c --- /dev/null +++ b/examples/islands/src/app.rs @@ -0,0 +1,25 @@ +use leptos::prelude::*; + +#[component] +pub fn App() -> impl IntoView { + view! { +
+

"My Application"

+
+
+ +
+ } +} + +#[island] +pub fn OuterIsland() -> impl IntoView { + view! { +
+

"Outer Island"

+ +
+ } +} diff --git a/examples/islands/src/error_template.rs b/examples/islands/src/error_template.rs new file mode 100644 index 000000000..d82dcec64 --- /dev/null +++ b/examples/islands/src/error_template.rs @@ -0,0 +1,47 @@ +use crate::errors::TodoAppError; +use leptos::prelude::*; +#[cfg(feature = "ssr")] +use leptos_axum::ResponseOptions; + +// A basic function to display errors served by the error boundaries. Feel free to do more complicated things +// here than just displaying them +#[component] +pub fn ErrorTemplate( + #[prop(optional)] outside_errors: Option, + #[prop(optional, into)] errors: Option>, +) -> impl IntoView { + let errors = match outside_errors { + Some(e) => RwSignal::new(e), + None => match errors { + Some(e) => e, + None => panic!("No Errors found and we expected errors!"), + }, + }; + + // Get Errors from Signal + // Downcast lets us take a type that implements `std::error::Error` + let errors = + move || errors.get().into_iter().map(|(_, v)| v).collect::>(); + + // Only the response code for the first error is actually sent from the server + // this may be customized by the specific application + /*#[cfg(feature = "ssr")] + { + let response = use_context::(); + if let Some(response) = response { + response.set_status(errors[0].status_code()); + } + }*/ + + view! { +

"Errors"

+ {move || { + errors() + .into_iter() + .map(|error| { + view! {

"Error: " {error.to_string()}

} + }) + .collect::>() + }} + } +} diff --git a/examples/islands/src/errors.rs b/examples/islands/src/errors.rs new file mode 100644 index 000000000..1f1aea92f --- /dev/null +++ b/examples/islands/src/errors.rs @@ -0,0 +1,21 @@ +use http::status::StatusCode; +use thiserror::Error; + +#[derive(Debug, Clone, Error)] +pub enum TodoAppError { + #[error("Not Found")] + NotFound, + #[error("Internal Server Error")] + InternalServerError, +} + +impl TodoAppError { + pub fn status_code(&self) -> StatusCode { + match self { + TodoAppError::NotFound => StatusCode::NOT_FOUND, + TodoAppError::InternalServerError => { + StatusCode::INTERNAL_SERVER_ERROR + } + } + } +} diff --git a/examples/islands/src/fallback.rs b/examples/islands/src/fallback.rs new file mode 100644 index 000000000..c7238f1ca --- /dev/null +++ b/examples/islands/src/fallback.rs @@ -0,0 +1,50 @@ +use crate::{error_template::ErrorTemplate, errors::TodoAppError}; +use axum::{ + body::Body, + extract::State, + http::{Request, Response, StatusCode, Uri}, + response::{IntoResponse, Response as AxumResponse}, +}; +use leptos::prelude::*; +use tower::ServiceExt; +use tower_http::services::ServeDir; + +pub async fn file_and_error_handler( + uri: Uri, + State(options): State, + req: Request, +) -> AxumResponse { + let root = options.site_root.clone(); + let res = get_static_file(uri.clone(), &root).await.unwrap(); + + if res.status() == StatusCode::OK { + res.into_response() + } else { + let mut errors = Errors::default(); + errors.insert_with_default_key(TodoAppError::NotFound); + let handler = leptos_axum::render_app_to_stream( + options.to_owned(), + move || view! { }, + ); + handler(req).await.into_response() + } +} + +async fn get_static_file( + uri: Uri, + root: &str, +) -> Result, (StatusCode, String)> { + let req = Request::builder() + .uri(uri.clone()) + .body(Body::empty()) + .unwrap(); + // `ServeDir` implements `tower::Service` so we can call it with `tower::ServiceExt::oneshot` + // This path is relative to the cargo root + match ServeDir::new(root).oneshot(req).await { + Ok(res) => Ok(res.into_response()), + Err(err) => Err(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Something went wrong: {err}"), + )), + } +} diff --git a/examples/islands/src/lib.rs b/examples/islands/src/lib.rs new file mode 100644 index 000000000..483c3c7ca --- /dev/null +++ b/examples/islands/src/lib.rs @@ -0,0 +1,29 @@ +pub mod app; +pub mod error_template; +pub mod errors; +#[cfg(feature = "ssr")] +pub mod fallback; + +#[cfg(feature = "hydrate")] +#[wasm_bindgen::prelude::wasm_bindgen] +pub fn hydrate() { + use crate::app::App; + /* use tracing_subscriber::fmt; + use tracing_subscriber_wasm::MakeConsoleWriter; + + fmt() + .with_writer( + // To avoide trace events in the browser from showing their + // JS backtrace, which is very annoying, in my opinion + MakeConsoleWriter::default() + .map_trace_level_to(tracing::Level::DEBUG), + ) + // For some reason, if we don't do this in the browser, we get + // a runtime error. + .without_time() + .init(); + _ = console_log::init_with_level(log::Level::Error);*/ + console_error_panic_hook::set_once(); + + //leptos::mount::hydrate_body(App); +} diff --git a/examples/islands/src/main.rs b/examples/islands/src/main.rs new file mode 100644 index 000000000..ce8480a53 --- /dev/null +++ b/examples/islands/src/main.rs @@ -0,0 +1,59 @@ +use crate::{fallback::file_and_error_handler, app::*}; +use axum::{ + body::Body, + extract::{Path, State}, + http::Request, + response::{IntoResponse, Response}, + routing::get, + Router, +}; +use leptos::prelude::*; +use leptos_axum::{generate_route_list, LeptosRoutes}; +use todo_app_sqlite_axum::*; + +#[tokio::main] +async fn main() { + simple_logger::init_with_level(log::Level::Error) + .expect("couldn't initialize logging"); + + // Setting this to None means we'll be using cargo-leptos and its env vars + let conf = get_configuration(None).await.unwrap(); + let leptos_options = conf.leptos_options; + let addr = leptos_options.site_addr; + let routes = generate_route_list(App); + + // build our application with a route + let app = Router::new() + .leptos_routes(&leptos_options, routes, { + let leptos_options = leptos_options.clone(); + move || { + use leptos::prelude::*; + + view! { + + + + + + + + + + + + + + + } + }}) + .fallback(file_and_error_handler) + .with_state(leptos_options); + + // run our app with hyper + // `axum::Server` is a re-export of `hyper::Server` + let listener = tokio::net::TcpListener::bind(&addr).await.unwrap(); + println!("listening on http://{}", &addr); + axum::serve(listener, app.into_make_service()) + .await + .unwrap(); +} diff --git a/examples/islands/style.css b/examples/islands/style.css new file mode 100644 index 000000000..9ba7149f9 --- /dev/null +++ b/examples/islands/style.css @@ -0,0 +1,3 @@ +.pending { + color: purple; +} diff --git a/hydration_context/src/csr.rs b/hydration_context/src/csr.rs index 85deddd2a..06b4e830e 100644 --- a/hydration_context/src/csr.rs +++ b/hydration_context/src/csr.rs @@ -67,5 +67,5 @@ impl SharedContext for CsrSharedContext { } #[inline(always)] - fn seal_errors(&self, boundary_id: &SerializedDataId) {} + fn seal_errors(&self, _boundary_id: &SerializedDataId) {} } diff --git a/hydration_context/src/ssr.rs b/hydration_context/src/ssr.rs index 6b8964a09..2b309a38e 100644 --- a/hydration_context/src/ssr.rs +++ b/hydration_context/src/ssr.rs @@ -149,11 +149,11 @@ impl SharedContext for SsrSharedContext { } fn get_is_hydrating(&self) -> bool { - self.is_hydrating.load(Ordering::Relaxed) + self.is_hydrating.load(Ordering::SeqCst) } fn set_is_hydrating(&self, is_hydrating: bool) { - self.is_hydrating.store(is_hydrating, Ordering::Relaxed) + self.is_hydrating.store(is_hydrating, Ordering::SeqCst) } fn errors(&self, boundary_id: &SerializedDataId) -> Vec<(ErrorId, Error)> { diff --git a/leptos/src/lib.rs b/leptos/src/lib.rs index bc4ebb7e6..569251c35 100644 --- a/leptos/src/lib.rs +++ b/leptos/src/lib.rs @@ -263,6 +263,11 @@ pub mod logging { pub use leptos_dom::{debug_warn, error, log, warn}; } +#[doc(hidden)] +pub use wasm_bindgen; // used in islands +#[doc(hidden)] +pub use web_sys; // used in islands + /*mod additional_attributes; pub use additional_attributes::*; mod await_; diff --git a/leptos_macro/src/component.rs b/leptos_macro/src/component.rs index aff66974c..a93912066 100644 --- a/leptos_macro/src/component.rs +++ b/leptos_macro/src/component.rs @@ -236,7 +236,7 @@ impl ToTokens for Model { let body_name = unmodified_fn_name_from_fn_name(&body_name); let body_expr = if *is_island { quote! { - ::leptos::reactive_graph::Owner::with_hydration(move || { + ::leptos::reactive_graph::owner::Owner::with_hydration(move || { #body_name(#prop_names) }) } @@ -447,9 +447,9 @@ impl ToTokens for Model { // TODO quote! { - #[::leptos::tachys::wasm_bindgen::prelude::wasm_bindgen(wasm_bindgen = ::leptos::wasm_bindgen)] + #[::leptos::wasm_bindgen::prelude::wasm_bindgen(wasm_bindgen = ::leptos::wasm_bindgen)] #[allow(non_snake_case)] - pub fn #hydrate_fn_name(el: ::leptos::tachys::web_sys::HtmlElement) { + pub fn #hydrate_fn_name(el: ::leptos::web_sys::HtmlElement) { #deserialize_island_props let island = #name(#island_props); let state = island.hydrate_from_position::(&el, ::leptos::tachys::view::Position::Current); diff --git a/reactive_graph/src/owner.rs b/reactive_graph/src/owner.rs index dd3598733..8f80de704 100644 --- a/reactive_graph/src/owner.rs +++ b/reactive_graph/src/owner.rs @@ -154,6 +154,29 @@ impl Owner { .and_then(|current| current.shared_context.clone()) }) } + + #[cfg(feature = "hydration")] + pub fn with_hydration(fun: impl FnOnce() -> T + 'static) -> T { + fn inner(fun: Box T>) -> T { + OWNER.with_borrow(|o| { + match o + .as_ref() + .and_then(|current| current.shared_context.as_ref()) + { + None => fun(), + Some(sc) => { + let prev = sc.get_is_hydrating(); + sc.set_is_hydrating(true); + let value = fun(); + sc.set_is_hydrating(prev); + value + } + } + }) + } + + inner(Box::new(fun)) + } } #[derive(Default)]