From 109244b28b05f3407e2b945292b15e8018db69bc Mon Sep 17 00:00:00 2001 From: Greg Johnston Date: Mon, 13 May 2024 10:20:59 -0400 Subject: [PATCH] feat: minimal island support in 0.7 --- examples/islands/Cargo.toml | 94 ++++++++++++++++++ examples/islands/LICENSE | 21 ++++ examples/islands/Makefile.toml | 12 +++ examples/islands/README.md | 19 ++++ examples/islands/e2e/Cargo.toml | 18 ++++ examples/islands/e2e/Makefile.toml | 20 ++++ examples/islands/e2e/README.md | 34 +++++++ .../islands/e2e/features/add_todo.feature | 16 +++ .../islands/e2e/features/delete_todo.feature | 18 ++++ .../islands/e2e/features/open_app.feature | 12 +++ examples/islands/e2e/tests/app_suite.rs | 14 +++ examples/islands/e2e/tests/fixtures/action.rs | 60 +++++++++++ examples/islands/e2e/tests/fixtures/check.rs | 57 +++++++++++ examples/islands/e2e/tests/fixtures/find.rs | 63 ++++++++++++ examples/islands/e2e/tests/fixtures/mod.rs | 4 + .../e2e/tests/fixtures/world/action_steps.rs | 57 +++++++++++ .../e2e/tests/fixtures/world/check_steps.rs | 67 +++++++++++++ .../islands/e2e/tests/fixtures/world/mod.rs | 39 ++++++++ examples/islands/public/favicon.ico | Bin 0 -> 15406 bytes examples/islands/rust-toolchain.toml | 2 + examples/islands/src/app.rs | 25 +++++ examples/islands/src/error_template.rs | 47 +++++++++ examples/islands/src/errors.rs | 21 ++++ examples/islands/src/fallback.rs | 50 ++++++++++ examples/islands/src/lib.rs | 29 ++++++ examples/islands/src/main.rs | 59 +++++++++++ examples/islands/style.css | 3 + hydration_context/src/csr.rs | 2 +- hydration_context/src/ssr.rs | 4 +- leptos/src/lib.rs | 5 + leptos_macro/src/component.rs | 6 +- reactive_graph/src/owner.rs | 23 +++++ 32 files changed, 895 insertions(+), 6 deletions(-) create mode 100644 examples/islands/Cargo.toml create mode 100644 examples/islands/LICENSE create mode 100644 examples/islands/Makefile.toml create mode 100644 examples/islands/README.md create mode 100644 examples/islands/e2e/Cargo.toml create mode 100644 examples/islands/e2e/Makefile.toml create mode 100644 examples/islands/e2e/README.md create mode 100644 examples/islands/e2e/features/add_todo.feature create mode 100644 examples/islands/e2e/features/delete_todo.feature create mode 100644 examples/islands/e2e/features/open_app.feature create mode 100644 examples/islands/e2e/tests/app_suite.rs create mode 100644 examples/islands/e2e/tests/fixtures/action.rs create mode 100644 examples/islands/e2e/tests/fixtures/check.rs create mode 100644 examples/islands/e2e/tests/fixtures/find.rs create mode 100644 examples/islands/e2e/tests/fixtures/mod.rs create mode 100644 examples/islands/e2e/tests/fixtures/world/action_steps.rs create mode 100644 examples/islands/e2e/tests/fixtures/world/check_steps.rs create mode 100644 examples/islands/e2e/tests/fixtures/world/mod.rs create mode 100644 examples/islands/public/favicon.ico create mode 100644 examples/islands/rust-toolchain.toml create mode 100644 examples/islands/src/app.rs create mode 100644 examples/islands/src/error_template.rs create mode 100644 examples/islands/src/errors.rs create mode 100644 examples/islands/src/fallback.rs create mode 100644 examples/islands/src/lib.rs create mode 100644 examples/islands/src/main.rs create mode 100644 examples/islands/style.css 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 0000000000000000000000000000000000000000..2ba8527cb12f5f28f331b8d361eef560492d4c77 GIT binary patch literal 15406 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 k<4FtN!5 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)]