diff --git a/examples/hackernews/src/routes/stories.rs b/examples/hackernews/src/routes/stories.rs index ccc3cda9b..808f91e8a 100644 --- a/examples/hackernews/src/routes/stories.rs +++ b/examples/hackernews/src/routes/stories.rs @@ -38,7 +38,7 @@ pub fn Stories(cx: Scope) -> impl IntoView { let (pending, set_pending) = create_signal(cx, false); let hide_more_link = - move || pending() || stories.read(cx).unwrap_or(None).unwrap_or_default().len() < 28; + move |cx| pending() || stories.read(cx).unwrap_or(None).unwrap_or_default().len() < 28; view! { cx, @@ -65,16 +65,20 @@ pub fn Stories(cx: Scope) -> impl IntoView { }} "page " {page} - "Loading..."

} > - - "more >" - -
+ + "more >" + + +
diff --git a/leptos/src/suspense.rs b/leptos/src/suspense.rs index f23764650..70331e8b9 100644 --- a/leptos/src/suspense.rs +++ b/leptos/src/suspense.rs @@ -79,9 +79,9 @@ where cfg_if! { if #[cfg(any(feature = "csr", feature = "hydrate"))] { if context.ready() { - orig_child(cx).into_view(cx) + Fragment::lazy(Box::new(|| vec![orig_child(cx).into_view(cx)])).into_view(cx) } else { - fallback().into_view(cx) + Fragment::lazy(Box::new(|| vec![fallback().into_view(cx)])).into_view(cx) } } else { use leptos_reactive::signal_prelude::*; @@ -108,10 +108,12 @@ where let orig_child = Rc::clone(&orig_child); move || { HydrationCtx::continue_from(current_id.clone()); - DynChild::new(move || orig_child(cx)) - .into_view(cx) - .render_to_string(cx) - .to_string() + Fragment::lazy(Box::new(move || { + vec![DynChild::new(move || orig_child(cx)).into_view(cx)] + })) + .into_view(cx) + .render_to_string(cx) + .to_string() } }, // in-order streaming @@ -119,11 +121,13 @@ where let current_id = current_id.clone(); move || { HydrationCtx::continue_from(current_id.clone()); - DynChild::new(move || orig_child(cx)) - .into_view(cx) - .into_stream_chunks(cx) + Fragment::lazy(Box::new(move || { + vec![DynChild::new(move || orig_child(cx)).into_view(cx)] + })) + .into_view(cx) + .into_stream_chunks(cx) } - } + }, ); // return the fallback for now, wrapped in fragment identifier diff --git a/leptos/tests/test_examples/suspense-tests/.gitignore b/leptos/tests/test_examples/suspense-tests/.gitignore new file mode 100644 index 000000000..8cdaa33de --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/.gitignore @@ -0,0 +1,13 @@ +# Generated by Cargo +# will have compiled files and executables +/target/ +pkg + +# These are backup files generated by rustfmt +**/*.rs.bk + +# node e2e test tools and outputs +node_modules/ +test-results/ +end2end/playwright-report/ +playwright/.cache/ diff --git a/leptos/tests/test_examples/suspense-tests/Cargo.toml b/leptos/tests/test_examples/suspense-tests/Cargo.toml new file mode 100644 index 000000000..4f06aacda --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/Cargo.toml @@ -0,0 +1,78 @@ +[package] +name = "leptos_start" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +actix-files = { version = "0.6", optional = true } +actix-web = { version = "4", optional = true, features = ["macros"] } +console_error_panic_hook = "0.1" +console_log = "1" +cfg-if = "1" +leptos = { path = "../../..", default-features = false, features = ["serde"] } +leptos_actix = { path = "../../../../integrations/actix", optional = true } +leptos_router = { path = "../../../../router", default-features = false } +log = "0.4" +simple_logger = "4" +wasm-bindgen = "0.2" +serde = "1.0.159" +tokio = { version = "1.27.0", features = ["time"], optional = true } + +[features] +hydrate = ["leptos/hydrate", "leptos_router/hydrate"] +ssr = [ + "dep:actix-files", + "dep:actix-web", + "dep:leptos_actix", + "leptos/ssr", + "leptos_router/ssr", + "dep:tokio", +] + +[package.metadata.leptos] +# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name +output-name = "leptos_start" +# 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" +# 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. +# [Windows] for non-WSL use "npx.cmd playwright test" +# This binary name can be checked in Powershell with Get-Command npx +end2end-cmd = "npx playwright test" +end2end-dir = "end2end" +# 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 + +[workspace] diff --git a/leptos/tests/test_examples/suspense-tests/LICENSE b/leptos/tests/test_examples/suspense-tests/LICENSE new file mode 100644 index 000000000..e869ce3b4 --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 henrik + +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/leptos/tests/test_examples/suspense-tests/README.md b/leptos/tests/test_examples/suspense-tests/README.md new file mode 100644 index 000000000..426f5a594 --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/README.md @@ -0,0 +1,61 @@ + + + Leptos Logo + + +# Leptos Starter Template + +This is a template for use with the [Leptos](https://github.com/leptos-rs/leptos) web framework and the [cargo-leptos](https://github.com/akesson/cargo-leptos) tool. + +## Creating your template repo + +If you don't have `cargo-leptos` installed you can install it with + +`cargo install cargo-leptos` + +Then run + +`cargo leptos new --git leptos-rs/start` + +to generate a new project template. + +`cd {projectname}` + +to go to your newly created project. + +Of course you should explore around the project structure, but the best place to start with your application code is in `src/app.rs`. + +## Running your project + +`cargo leptos watch` + +## Installing Additional Tools + +By default, `cargo-leptos` uses `nightly` Rust, `cargo-generate`, and `sass`. If you run into any trouble, you may need to install one or more of these tools. + +1. `rustup toolchain install nightly --allow-downgrade` - make sure you have Rust nightly +2. `rustup default nightly` - setup nightly as default, or you can use rust-toolchain file later on +3. `rustup target add wasm32-unknown-unknown` - add the ability to compile Rust to WebAssembly +4. `cargo install cargo-generate` - install `cargo-generate` binary (should be installed automatically in future) +5. `npm install -g sass` - install `dart-sass` (should be optional in future) + +## Executing a Server on a Remote Machine Without the Toolchain +After running a `cargo leptos build --release` the minimum files needed are: + +1. The server binary located in `target/server/release` +2. The `site` directory and all files within located in `target/site` + +Copy these files to your remote server. The directory structure should be: +```text +leptos_start +site/ +``` +Set the following enviornment variables (updating for your project as needed): +```text +LEPTOS_OUTPUT_NAME="leptos_start" +LEPTOS_SITE_ROOT="site" +LEPTOS_SITE_PKG_DIR="pkg" +LEPTOS_SITE_ADDR="127.0.0.1:3000" +LEPTOS_RELOAD_PORT="3001" +``` +Finally, run the server binary. diff --git a/leptos/tests/test_examples/suspense-tests/end2end/package-lock.json b/leptos/tests/test_examples/suspense-tests/end2end/package-lock.json new file mode 100644 index 000000000..f12af4425 --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/end2end/package-lock.json @@ -0,0 +1,74 @@ +{ + "name": "end2end", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "end2end", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.28.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.0.tgz", + "integrity": "sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ==", + "dev": true, + "dependencies": { + "@types/node": "*", + "playwright-core": "1.28.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/@types/node": { + "version": "18.11.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz", + "integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==", + "dev": true + }, + "node_modules/playwright-core": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.0.tgz", + "integrity": "sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA==", + "dev": true, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=14" + } + } + }, + "dependencies": { + "@playwright/test": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.28.0.tgz", + "integrity": "sha512-vrHs5DFTPwYox5SGKq/7TDn/S4q6RA1zArd7uhO6EyP9hj3XgZBBM12ktMbnDQNxh/fL1IUKsTNLxihmsU38lQ==", + "dev": true, + "requires": { + "@types/node": "*", + "playwright-core": "1.28.0" + } + }, + "@types/node": { + "version": "18.11.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.9.tgz", + "integrity": "sha512-CRpX21/kGdzjOpFsZSkcrXMGIBWMGNIHXXBVFSH+ggkftxg+XYP20TESbh+zFvFj3EQOl5byk0HTRn1IL6hbqg==", + "dev": true + }, + "playwright-core": { + "version": "1.28.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.28.0.tgz", + "integrity": "sha512-nJLknd28kPBiCNTbqpu6Wmkrh63OEqJSFw9xOfL9qxfNwody7h6/L3O2dZoWQ6Oxcm0VOHjWmGiCUGkc0X3VZA==", + "dev": true + } + } +} diff --git a/leptos/tests/test_examples/suspense-tests/end2end/package.json b/leptos/tests/test_examples/suspense-tests/end2end/package.json new file mode 100644 index 000000000..ed785859f --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/end2end/package.json @@ -0,0 +1,13 @@ +{ + "name": "end2end", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": {}, + "keywords": [], + "author": "", + "license": "ISC", + "devDependencies": { + "@playwright/test": "^1.28.0" + } +} diff --git a/leptos/tests/test_examples/suspense-tests/end2end/playwright.config.ts b/leptos/tests/test_examples/suspense-tests/end2end/playwright.config.ts new file mode 100644 index 000000000..e9891c094 --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/end2end/playwright.config.ts @@ -0,0 +1,107 @@ +import type { PlaywrightTestConfig } from "@playwright/test"; +import { devices } from "@playwright/test"; + +/** + * Read environment variables from file. + * https://github.com/motdotla/dotenv + */ +// require('dotenv').config(); + +/** + * See https://playwright.dev/docs/test-configuration. + */ +const config: PlaywrightTestConfig = { + testDir: "./tests", + /* Maximum time one test can run for. */ + timeout: 30 * 1000, + expect: { + /** + * Maximum time expect() should wait for the condition to be met. + * For example in `await expect(locator).toHaveText();` + */ + timeout: 5000, + }, + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: "html", + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ + actionTimeout: 0, + /* Base URL to use in actions like `await page.goto('/')`. */ + // baseURL: 'http://localhost:3000', + + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: "on-first-retry", + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: "chromium", + use: { + ...devices["Desktop Chrome"], + }, + }, + + { + name: "firefox", + use: { + ...devices["Desktop Firefox"], + }, + }, + + { + name: "webkit", + use: { + ...devices["Desktop Safari"], + }, + }, + + /* Test against mobile viewports. */ + // { + // name: 'Mobile Chrome', + // use: { + // ...devices['Pixel 5'], + // }, + // }, + // { + // name: 'Mobile Safari', + // use: { + // ...devices['iPhone 12'], + // }, + // }, + + /* Test against branded browsers. */ + // { + // name: 'Microsoft Edge', + // use: { + // channel: 'msedge', + // }, + // }, + // { + // name: 'Google Chrome', + // use: { + // channel: 'chrome', + // }, + // }, + ], + + /* Folder for test artifacts such as screenshots, videos, traces, etc. */ + // outputDir: 'test-results/', + + /* Run your local dev server before starting the tests */ + // webServer: { + // command: 'npm run start', + // port: 3000, + // }, +}; + +export default config; diff --git a/leptos/tests/test_examples/suspense-tests/end2end/tests/example.spec.ts b/leptos/tests/test_examples/suspense-tests/end2end/tests/example.spec.ts new file mode 100644 index 000000000..a461f351a --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/end2end/tests/example.spec.ts @@ -0,0 +1,9 @@ +import { test, expect } from "@playwright/test"; + +test("homepage has title and links to intro page", async ({ page }) => { + await page.goto("http://localhost:3000/"); + + await expect(page).toHaveTitle("Welcome to Leptos"); + + await expect(page.locator("h1")).toHaveText("Welcome to Leptos!"); +}); diff --git a/leptos/tests/test_examples/suspense-tests/src/app.rs b/leptos/tests/test_examples/suspense-tests/src/app.rs new file mode 100644 index 000000000..04cd2254a --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/src/app.rs @@ -0,0 +1,219 @@ +use leptos::*; +use leptos_router::*; + +#[server(OneSecondFn "/api")] +async fn one_second_fn(query: ()) -> Result<(), ServerFnError> { + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + + Ok(()) +} + +#[server(TwoSecondFn "/api")] +async fn two_second_fn(query: ()) -> Result<(), ServerFnError> { + tokio::time::sleep(tokio::time::Duration::from_secs(2)).await; + + Ok(()) +} + +#[component] +pub fn App(cx: Scope) -> impl IntoView { + let style = r#" + nav { + display: flex; + width: 100%; + justify-content: space-around; + } + + [aria-current] { + font-weight: bold; + } + "#; + view! { + cx, + + + +
+ + } + /> + // out-of-order + +

"Out-of-Order"

+ + } + > + }/> + }/> + }/> + }/> + + // in-order + +

"In-Order"

+ + } + > + }/> + }/> + }/> + }/> + + // async + +

"Async"

+ + } + > + }/> + }/> + }/> + }/> + +
+
+
+ } +} + +#[component] +fn SecondaryNav(cx: Scope) -> impl IntoView { + view! { cx, + + } +} + +#[component] +fn Nested(cx: Scope) -> impl IntoView { + let one_second = create_resource(cx, || (), one_second_fn); + let two_second = create_resource(cx, || (), two_second_fn); + + view! { cx, +
+ + "One Second: " + {move || { + one_second.read(cx).map(|_| "Loaded 1!") + }} +

+ + "Two Second: " + {move || { + two_second.read(cx).map(|_| "Loaded 2!") + }} + +
+
+ } +} + +#[component] +fn Parallel(cx: Scope) -> impl IntoView { + let one_second = create_resource(cx, || (), one_second_fn); + let two_second = create_resource(cx, || (), two_second_fn); + let (count, set_count) = create_signal(cx, 0); + + view! { cx, +
+ + "One Second: " + {move || { + one_second.read(cx).map(move |_| view! { cx, + "Loaded 1" + + }) + }} + +

+ + "Two Second: " + {move || { + two_second.read(cx).map(move |_| view! { cx, + "Loaded 2" + + }) + }} + +
+ } +} + +#[component] +fn Single(cx: Scope) -> impl IntoView { + let one_second = create_resource(cx, || (), one_second_fn); + let (count, set_count) = create_signal(cx, 0); + + view! { cx, +
+ + "One Second: " + {move || { + one_second.read(cx).map(|_| "Loaded 1!") + }} + +

"Children following " "" " should hydrate properly."

+
+ +
+
+ } +} + +#[component] +fn InsideComponent(cx: Scope) -> impl IntoView { + let (count, set_count) = create_signal(cx, 0); + + view! { cx, +
+

"" " inside another component should work."

+ +

"Children following " "" " should hydrate properly."

+
+ +
+
+ } +} + +#[component] +fn InsideComponentChild(cx: Scope) -> impl IntoView { + let one_second = create_resource(cx, || (), one_second_fn); + view! { cx, + + "One Second: " + {move || { + one_second.read(cx).map(|_| "Loaded 1!") + }} + + } +} diff --git a/leptos/tests/test_examples/suspense-tests/src/lib.rs b/leptos/tests/test_examples/suspense-tests/src/lib.rs new file mode 100644 index 000000000..c9c33ac40 --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/src/lib.rs @@ -0,0 +1,23 @@ +pub mod app; +use cfg_if::cfg_if; + +cfg_if! { +if #[cfg(feature = "hydrate")] { + + use wasm_bindgen::prelude::wasm_bindgen; + + #[wasm_bindgen] + pub fn hydrate() { + use app::*; + use leptos::*; + + // initializes logging using the `log` crate + _ = console_log::init_with_level(log::Level::Debug); + console_error_panic_hook::set_once(); + + leptos::mount_to_body(move |cx| { + view! { cx, } + }); + } +} +} diff --git a/leptos/tests/test_examples/suspense-tests/src/main.rs b/leptos/tests/test_examples/suspense-tests/src/main.rs new file mode 100644 index 000000000..6f52793b8 --- /dev/null +++ b/leptos/tests/test_examples/suspense-tests/src/main.rs @@ -0,0 +1,42 @@ +#[cfg(feature = "ssr")] +#[actix_web::main] +async fn main() -> std::io::Result<()> { + use actix_files::Files; + use actix_web::*; + use leptos::*; + use leptos_actix::{generate_route_list, LeptosRoutes}; + use leptos_start::app::*; + + let conf = get_configuration(None).await.unwrap(); + let addr = conf.leptos_options.site_addr; + // Generate the list of routes in your Leptos App + let routes = generate_route_list(|cx| view! { cx, }); + + OneSecondFn::register().unwrap(); + TwoSecondFn::register().unwrap(); + + HttpServer::new(move || { + let leptos_options = &conf.leptos_options; + let site_root = &leptos_options.site_root; + + App::new() + .route("/api/{tail:.*}", leptos_actix::handle_server_fns()) + .leptos_routes( + leptos_options.to_owned(), + routes.to_owned(), + |cx| view! { cx, }, + ) + .service(Files::new("/", site_root)) + //.wrap(middleware::Compress::default()) + }) + .bind(addr)? + .run() + .await +} + +#[cfg(not(feature = "ssr"))] +pub fn main() { + // no client-side main function + // unless we want this to work with e.g., Trunk for pure client-side testing + // see lib.rs for hydration function instead +} diff --git a/leptos_dom/src/ssr.rs b/leptos_dom/src/ssr.rs index 4e0baf00a..c6b128cd3 100644 --- a/leptos_dom/src/ssr.rs +++ b/leptos_dom/src/ssr.rs @@ -219,8 +219,8 @@ fn fragments_to_chunks(