Fix ssg race condition (#3521)
* Fix server crash logging and opening fullstack apps * Fix race condition during SSG * move route prerendering into a separate stage * Fix static site generation with dx serve * only proxy the fullstack server if ssg is disabled * test nested-suspense with ssg in playwright
This commit is contained in:
1 parent
343c48f5ad
commit
ecc419df0a
18 files changed
+370
-214
No files matched your search
Generated
-1
@@ -10243,7 +10243,6 @@ dependencies = [
|
|||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"futures-channel",
|
|
||||||
"futures-core",
|
"futures-core",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
"h2 0.4.7",
|
"h2 0.4.7",
|
||||||
|
|||||||
@@ -67,9 +67,8 @@ dunce = { workspace = true }
|
|||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
reqwest = { workspace = true, features = [
|
reqwest = { workspace = true, features = [
|
||||||
"rustls-tls",
|
"rustls-tls",
|
||||||
"stream",
|
|
||||||
"trust-dns",
|
"trust-dns",
|
||||||
"blocking",
|
"json"
|
||||||
] }
|
] }
|
||||||
tower = { workspace = true }
|
tower = { workspace = true }
|
||||||
once_cell = "1.19.0"
|
once_cell = "1.19.0"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
use super::prerender::pre_render_static_routes;
|
||||||
use super::templates::InfoPlistData;
|
use super::templates::InfoPlistData;
|
||||||
use crate::wasm_bindgen::WasmBindgenBuilder;
|
use crate::wasm_bindgen::WasmBindgenBuilder;
|
||||||
use crate::{BuildRequest, Platform};
|
use crate::{BuildRequest, Platform};
|
||||||
@@ -284,6 +285,7 @@ impl AppBundle {
|
|||||||
.context("Failed to write assets")?;
|
.context("Failed to write assets")?;
|
||||||
bundle.write_metadata().await?;
|
bundle.write_metadata().await?;
|
||||||
bundle.optimize().await?;
|
bundle.optimize().await?;
|
||||||
|
bundle.pre_render_ssg_routes().await?;
|
||||||
bundle
|
bundle
|
||||||
.assemble()
|
.assemble()
|
||||||
.await
|
.await
|
||||||
@@ -558,11 +560,6 @@ impl AppBundle {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.unwrap()?;
|
.unwrap()?;
|
||||||
|
|
||||||
// Run SSG and cache static routes
|
|
||||||
if self.build.build.ssg {
|
|
||||||
self.run_ssg().await?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Platform::MacOS => {}
|
Platform::MacOS => {}
|
||||||
Platform::Windows => {}
|
Platform::Windows => {}
|
||||||
@@ -686,76 +683,18 @@ impl AppBundle {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_ssg(&self) -> anyhow::Result<()> {
|
async fn pre_render_ssg_routes(&self) -> Result<()> {
|
||||||
use futures_util::stream::FuturesUnordered;
|
// Run SSG and cache static routes
|
||||||
use futures_util::StreamExt;
|
if !self.build.build.ssg {
|
||||||
use tokio::process::Command;
|
return Ok(());
|
||||||
|
}
|
||||||
let fullstack_address = dioxus_cli_config::fullstack_address_or_localhost();
|
self.build.status_prerendering_routes();
|
||||||
let address = fullstack_address.ip().to_string();
|
pre_render_static_routes(
|
||||||
let port = fullstack_address.port().to_string();
|
&self
|
||||||
|
.server_exe()
|
||||||
tracing::info!("Running SSG");
|
|
||||||
|
|
||||||
// Run the server executable
|
|
||||||
let _child = Command::new(
|
|
||||||
self.server_exe()
|
|
||||||
.context("Failed to find server executable")?,
|
.context("Failed to find server executable")?,
|
||||||
)
|
)
|
||||||
.env(dioxus_cli_config::SERVER_PORT_ENV, port.clone())
|
.await?;
|
||||||
.env(dioxus_cli_config::SERVER_IP_ENV, address.clone())
|
|
||||||
.stdout(std::process::Stdio::piped())
|
|
||||||
.stderr(std::process::Stdio::piped())
|
|
||||||
.kill_on_drop(true)
|
|
||||||
.spawn()?;
|
|
||||||
|
|
||||||
// Wait a second for the server to start
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
|
||||||
|
|
||||||
// Get the routes from the `/static_routes` endpoint
|
|
||||||
let mut routes = reqwest::Client::builder()
|
|
||||||
.build()?
|
|
||||||
.post(format!("http://{address}:{port}/api/static_routes"))
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
.context("Failed to get static routes from server")?
|
|
||||||
.text()
|
|
||||||
.await
|
|
||||||
.map(|raw| serde_json::from_str::<Vec<String>>(&raw).unwrap())
|
|
||||||
.inspect(|text| tracing::debug!("Got static routes: {text:?}"))
|
|
||||||
.context("Failed to parse static routes from server")?
|
|
||||||
.into_iter()
|
|
||||||
.map(|line| {
|
|
||||||
let port = port.clone();
|
|
||||||
let address = address.clone();
|
|
||||||
async move {
|
|
||||||
tracing::info!("SSG: {line}");
|
|
||||||
reqwest::Client::builder()
|
|
||||||
.build()?
|
|
||||||
.get(format!("http://{address}:{port}{line}"))
|
|
||||||
.header("Accept", "text/html")
|
|
||||||
.send()
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.collect::<FuturesUnordered<_>>();
|
|
||||||
|
|
||||||
while let Some(route) = routes.next().await {
|
|
||||||
match route {
|
|
||||||
Ok(route) => tracing::debug!("ssg success: {route:?}"),
|
|
||||||
Err(err) => tracing::error!("ssg error: {err:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait a second for the cache to be written by the server
|
|
||||||
tracing::info!("Waiting a moment for isrg to propagate...");
|
|
||||||
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(10)).await;
|
|
||||||
|
|
||||||
tracing::info!("SSG complete");
|
|
||||||
|
|
||||||
drop(_child);
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
mod builder;
|
mod builder;
|
||||||
mod bundle;
|
mod bundle;
|
||||||
|
mod prerender;
|
||||||
mod progress;
|
mod progress;
|
||||||
mod request;
|
mod request;
|
||||||
mod templates;
|
mod templates;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
use anyhow::Context;
|
||||||
|
use dioxus_cli_config::{server_ip, server_port};
|
||||||
|
use futures_util::stream::FuturesUnordered;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use std::{
|
||||||
|
net::{IpAddr, Ipv4Addr, SocketAddr},
|
||||||
|
path::Path,
|
||||||
|
time::Duration,
|
||||||
|
};
|
||||||
|
use tokio::process::Command;
|
||||||
|
|
||||||
|
pub(crate) async fn pre_render_static_routes(server_exe: &Path) -> anyhow::Result<()> {
|
||||||
|
// Use the address passed in through environment variables or default to localhost:9999. We need
|
||||||
|
// to default to a value that is different than the CLI default address to avoid conflicts
|
||||||
|
let ip = server_ip().unwrap_or_else(|| IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
|
||||||
|
let port = server_port().unwrap_or(9999);
|
||||||
|
let fullstack_address = SocketAddr::new(ip, port);
|
||||||
|
let address = fullstack_address.ip().to_string();
|
||||||
|
let port = fullstack_address.port().to_string();
|
||||||
|
// Borrow port and address so we can easily moe them into multiple tasks below
|
||||||
|
let address = &address;
|
||||||
|
let port = &port;
|
||||||
|
|
||||||
|
tracing::info!("Running SSG at http://{address}:{port}");
|
||||||
|
|
||||||
|
// Run the server executable
|
||||||
|
let _child = Command::new(server_exe)
|
||||||
|
.env(dioxus_cli_config::SERVER_PORT_ENV, port)
|
||||||
|
.env(dioxus_cli_config::SERVER_IP_ENV, address)
|
||||||
|
.stdout(std::process::Stdio::piped())
|
||||||
|
.stderr(std::process::Stdio::piped())
|
||||||
|
.kill_on_drop(true)
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let reqwest_client = reqwest::Client::new();
|
||||||
|
// Borrow reqwest_client so we only move the reference into the futures
|
||||||
|
let reqwest_client = &reqwest_client;
|
||||||
|
|
||||||
|
// Get the routes from the `/static_routes` endpoint
|
||||||
|
let mut routes = None;
|
||||||
|
|
||||||
|
// The server may take a few seconds to start up. Try fetching the route up to 5 times with a one second delay
|
||||||
|
const RETRY_ATTEMPTS: usize = 5;
|
||||||
|
for i in 0..=RETRY_ATTEMPTS {
|
||||||
|
let request = reqwest_client
|
||||||
|
.post(format!("http://{address}:{port}/api/static_routes"))
|
||||||
|
.send()
|
||||||
|
.await;
|
||||||
|
match request {
|
||||||
|
Ok(request) => {
|
||||||
|
routes = Some(request
|
||||||
|
.json::<Vec<String>>()
|
||||||
|
.await
|
||||||
|
.inspect(|text| tracing::debug!("Got static routes: {text:?}"))
|
||||||
|
.context("Failed to parse static routes from the server. Make sure your server function returns Vec<String> with the (default) json encoding")?);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
// If the request fails, try up to 5 times with a one second delay
|
||||||
|
// If it fails 5 times, return the error
|
||||||
|
if i == RETRY_ATTEMPTS {
|
||||||
|
return Err(err).context("Failed to get static routes from server. Make sure you have a server function at the `/api/static_routes` endpoint that returns Vec<String> of static routes.");
|
||||||
|
}
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let routes = routes.expect(
|
||||||
|
"static routes should exist or an error should have been returned on the last attempt",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create a pool of futures that cache each route
|
||||||
|
let mut resolved_routes = routes
|
||||||
|
.into_iter()
|
||||||
|
.map(|route| async move {
|
||||||
|
tracing::info!("Rendering {route} for SSG");
|
||||||
|
// For each route, ping the server to force it to cache the response for ssg
|
||||||
|
let request = reqwest_client
|
||||||
|
.get(format!("http://{address}:{port}{route}"))
|
||||||
|
.header("Accept", "text/html")
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
// If it takes longer than 30 seconds to resolve the route, log a warning
|
||||||
|
let warning_task = tokio::spawn({
|
||||||
|
let route = route.clone();
|
||||||
|
async move {
|
||||||
|
tokio::time::sleep(Duration::from_secs(30)).await;
|
||||||
|
tracing::warn!("Route {route} has been rendering for 30 seconds");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Wait for the streaming response to completely finish before continuing. We don't use the html it returns directly
|
||||||
|
// because it may contain artifacts of intermediate streaming steps while the page is loading. The SSG app should write
|
||||||
|
// the final clean HTML to the disk automatically after the request completes.
|
||||||
|
let _html = request.text().await?;
|
||||||
|
|
||||||
|
// Cancel the warning task if it hasn't already run
|
||||||
|
warning_task.abort();
|
||||||
|
|
||||||
|
Ok::<_, reqwest::Error>(route)
|
||||||
|
})
|
||||||
|
.collect::<FuturesUnordered<_>>();
|
||||||
|
|
||||||
|
while let Some(route) = resolved_routes.next().await {
|
||||||
|
match route {
|
||||||
|
Ok(route) => tracing::debug!("ssg success: {route:?}"),
|
||||||
|
Err(err) => tracing::error!("ssg error: {err:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("SSG complete");
|
||||||
|
|
||||||
|
drop(_child);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -90,6 +90,12 @@ impl BuildRequest {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn status_prerendering_routes(&self) {
|
||||||
|
_ = self.progress.unbounded_send(BuildUpdate::Progress {
|
||||||
|
stage: BuildStage::PrerenderingRoutes {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn status_installing_tooling(&self) {
|
pub(crate) fn status_installing_tooling(&self) {
|
||||||
_ = self.progress.unbounded_send(BuildUpdate::Progress {
|
_ = self.progress.unbounded_send(BuildUpdate::Progress {
|
||||||
stage: BuildStage::InstallingTooling {},
|
stage: BuildStage::InstallingTooling {},
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ impl ServeArgs {
|
|||||||
pub(crate) fn should_proxy_build(&self) -> bool {
|
pub(crate) fn should_proxy_build(&self) -> bool {
|
||||||
match self.build_arguments.platform() {
|
match self.build_arguments.platform() {
|
||||||
Platform::Server => true,
|
Platform::Server => true,
|
||||||
_ => self.build_arguments.fullstack,
|
// During SSG, just serve the static files instead of running the server
|
||||||
|
_ => self.build_arguments.fullstack && !self.build_arguments.ssg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,13 +60,9 @@ impl AppHandle {
|
|||||||
pub(crate) async fn open(
|
pub(crate) async fn open(
|
||||||
&mut self,
|
&mut self,
|
||||||
devserver_ip: SocketAddr,
|
devserver_ip: SocketAddr,
|
||||||
fullstack_address: Option<SocketAddr>,
|
start_fullstack_on_address: Option<SocketAddr>,
|
||||||
open_browser: bool,
|
open_browser: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
if let Some(addr) = fullstack_address {
|
|
||||||
tracing::debug!("Proxying fullstack server from port {:?}", addr);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the env vars that the clients will expect
|
// Set the env vars that the clients will expect
|
||||||
// These need to be stable within a release version (ie 0.6.0)
|
// These need to be stable within a release version (ie 0.6.0)
|
||||||
let mut envs = vec![
|
let mut envs = vec![
|
||||||
@@ -89,13 +85,12 @@ impl AppHandle {
|
|||||||
envs.push((dioxus_cli_config::ASSET_ROOT_ENV, base_path.clone()));
|
envs.push((dioxus_cli_config::ASSET_ROOT_ENV, base_path.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(addr) = fullstack_address {
|
// Launch the server if we were given an address to start it on, and the build includes a server. After we
|
||||||
|
// start the server, consume its stdout/stderr.
|
||||||
|
if let (Some(addr), Some(server)) = (start_fullstack_on_address, self.app.server_exe()) {
|
||||||
|
tracing::debug!("Proxying fullstack server from port {:?}", addr);
|
||||||
envs.push((dioxus_cli_config::SERVER_IP_ENV, addr.ip().to_string()));
|
envs.push((dioxus_cli_config::SERVER_IP_ENV, addr.ip().to_string()));
|
||||||
envs.push((dioxus_cli_config::SERVER_PORT_ENV, addr.port().to_string()));
|
envs.push((dioxus_cli_config::SERVER_PORT_ENV, addr.port().to_string()));
|
||||||
}
|
|
||||||
|
|
||||||
// Launch the server if we have one and consume its stdout/stderr
|
|
||||||
if let Some(server) = self.app.server_exe() {
|
|
||||||
tracing::debug!("Launching server from path: {server:?}");
|
tracing::debug!("Launching server from path: {server:?}");
|
||||||
let mut child = Command::new(server)
|
let mut child = Command::new(server)
|
||||||
.envs(envs.clone())
|
.envs(envs.clone())
|
||||||
|
|||||||
@@ -482,6 +482,7 @@ impl Output {
|
|||||||
lines.push(krate.as_str().dark_gray())
|
lines.push(krate.as_str().dark_gray())
|
||||||
}
|
}
|
||||||
BuildStage::OptimizingWasm {} => lines.push("Optimizing wasm".yellow()),
|
BuildStage::OptimizingWasm {} => lines.push("Optimizing wasm".yellow()),
|
||||||
|
BuildStage::PrerenderingRoutes {} => lines.push("Prerendering static routes".yellow()),
|
||||||
BuildStage::RunningBindgen {} => lines.push("Running wasm-bindgen".yellow()),
|
BuildStage::RunningBindgen {} => lines.push("Running wasm-bindgen".yellow()),
|
||||||
BuildStage::RunningGradle {} => lines.push("Running gradle assemble".yellow()),
|
BuildStage::RunningGradle {} => lines.push("Running gradle assemble".yellow()),
|
||||||
BuildStage::Bundling {} => lines.push("Bundling app".yellow()),
|
BuildStage::Bundling {} => lines.push("Bundling app".yellow()),
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ impl AppRunner {
|
|||||||
},
|
},
|
||||||
Some(status) = OptionFuture::from(handle.server_child.as_mut().map(|f| f.wait())) => {
|
Some(status) = OptionFuture::from(handle.server_child.as_mut().map(|f| f.wait())) => {
|
||||||
match status {
|
match status {
|
||||||
Ok(status) => ProcessExited { status, platform: Platform::Server },
|
Ok(status) => ProcessExited { status, platform },
|
||||||
Err(_err) => todo!("handle error in process joining?"),
|
Err(_err) => todo!("handle error in process joining?"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,18 +383,18 @@ fn build_devserver_router(
|
|||||||
// For fullstack, liveview, and server, forward all requests to the inner server
|
// For fullstack, liveview, and server, forward all requests to the inner server
|
||||||
let address = fullstack_address.unwrap();
|
let address = fullstack_address.unwrap();
|
||||||
router = router.nest_service("/",super::proxy::proxy_to(
|
router = router.nest_service("/",super::proxy::proxy_to(
|
||||||
format!("http://{address}").parse().unwrap(),
|
format!("http://{address}").parse().unwrap(),
|
||||||
true,
|
true,
|
||||||
|error| {
|
|error| {
|
||||||
Response::builder()
|
Response::builder()
|
||||||
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
.status(StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
.body(Body::from(format!(
|
.body(Body::from(format!(
|
||||||
"Backend connection failed. The backend is likely still starting up. Please try again in a few seconds. Error: {:#?}",
|
"Backend connection failed. The backend is likely still starting up. Please try again in a few seconds. Error: {:#?}",
|
||||||
error
|
error
|
||||||
)))
|
)))
|
||||||
.unwrap()
|
.unwrap()
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
// Otherwise, just serve the dir ourselves
|
// Otherwise, just serve the dir ourselves
|
||||||
// Route file service to output the .wasm and assets if this is a web build
|
// Route file service to output the .wasm and assets if this is a web build
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ pub enum BuildStage {
|
|||||||
},
|
},
|
||||||
RunningBindgen,
|
RunningBindgen,
|
||||||
OptimizingWasm,
|
OptimizingWasm,
|
||||||
|
PrerenderingRoutes,
|
||||||
CopyingAssets {
|
CopyingAssets {
|
||||||
current: usize,
|
current: usize,
|
||||||
total: usize,
|
total: usize,
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// @ts-check
|
||||||
|
const { test, expect } = require("@playwright/test");
|
||||||
|
|
||||||
|
test("nested suspense resolves", async ({ page }) => {
|
||||||
|
// Wait for the dev server to reload
|
||||||
|
await page.goto("http://localhost:5050");
|
||||||
|
// Then wait for the page to start loading
|
||||||
|
await page.goto("http://localhost:5050", { waitUntil: "commit" });
|
||||||
|
|
||||||
|
// Expect the page to contain the suspense result from the server
|
||||||
|
const mainMessageTitle = page.locator("#title-0");
|
||||||
|
await expect(mainMessageTitle).toContainText("The robot says hello world");
|
||||||
|
const mainMessageBody = page.locator("#body-0");
|
||||||
|
await expect(mainMessageBody).toContainText(
|
||||||
|
"The robot becomes sentient and says hello world"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And expect the title to have resolved on the client
|
||||||
|
await expect(page).toHaveTitle("The robot says hello world");
|
||||||
|
|
||||||
|
// Nested suspense should be resolved
|
||||||
|
const nestedMessageTitle1 = page.locator("#title-1");
|
||||||
|
await expect(nestedMessageTitle1).toContainText("The world says hello back");
|
||||||
|
const nestedMessageBody1 = page.locator("#body-1");
|
||||||
|
await expect(nestedMessageBody1).toContainText(
|
||||||
|
"In a stunning turn of events, the world collectively unites and says hello back"
|
||||||
|
);
|
||||||
|
|
||||||
|
const nestedMessageDiv2 = page.locator("#children-2");
|
||||||
|
await expect(nestedMessageDiv2).toBeEmpty();
|
||||||
|
const nestedMessageTitle2 = page.locator("#title-2");
|
||||||
|
await expect(nestedMessageTitle2).toContainText("Goodbye Robot");
|
||||||
|
const nestedMessageBody2 = page.locator("#body-2");
|
||||||
|
await expect(nestedMessageBody2).toContainText("The robot says goodbye");
|
||||||
|
|
||||||
|
const nestedMessageDiv3 = page.locator("#children-3");
|
||||||
|
await expect(nestedMessageDiv3).toBeEmpty();
|
||||||
|
const nestedMessageTitle3 = page.locator("#title-3");
|
||||||
|
await expect(nestedMessageTitle3).toContainText("Goodbye World");
|
||||||
|
const nestedMessageBody3 = page.locator("#body-3");
|
||||||
|
await expect(nestedMessageBody3).toContainText("The world says goodbye");
|
||||||
|
|
||||||
|
// Deeply nested suspense should be resolved
|
||||||
|
const nestedMessageDiv4 = page.locator("#children-4");
|
||||||
|
await expect(nestedMessageDiv4).toBeEmpty();
|
||||||
|
const nestedMessageTitle4 = page.locator("#title-4");
|
||||||
|
await expect(nestedMessageTitle4).toContainText("Hello World");
|
||||||
|
const nestedMessageBody4 = page.locator("#body-4");
|
||||||
|
await expect(nestedMessageBody4).toContainText("The world says hello again");
|
||||||
|
});
|
||||||
@@ -15,3 +15,12 @@ tokio = { workspace = true, features = ["full"], optional = true }
|
|||||||
default = []
|
default = []
|
||||||
server = ["dioxus/server", "tokio"]
|
server = ["dioxus/server", "tokio"]
|
||||||
web = ["dioxus/web"]
|
web = ["dioxus/web"]
|
||||||
|
|
||||||
|
# We need a separate bin for the SSG build to avoid conflicting server caches
|
||||||
|
[[bin]]
|
||||||
|
name = "nested-suspense-ssg"
|
||||||
|
path = "src/ssg.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "nested-suspense"
|
||||||
|
path = "src/main.rs"
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// This test is used by playwright configured in the root of the repo
|
||||||
|
// Tests:
|
||||||
|
// - SEO without JS
|
||||||
|
// - Streaming hydration
|
||||||
|
// - Suspense
|
||||||
|
// - Server functions
|
||||||
|
//
|
||||||
|
// Without Javascript, content may not load into the right location, but it should still be somewhere in the html even if it is invisible
|
||||||
|
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
pub fn app() -> Element {
|
||||||
|
rsx! {
|
||||||
|
SuspenseBoundary {
|
||||||
|
fallback: move |_| rsx! {},
|
||||||
|
document::Style {
|
||||||
|
href: asset!("/assets/style.css")
|
||||||
|
}
|
||||||
|
LoadTitle {}
|
||||||
|
}
|
||||||
|
MessageWithLoader { id: 0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn MessageWithLoader(id: usize) -> Element {
|
||||||
|
rsx! {
|
||||||
|
SuspenseBoundary {
|
||||||
|
fallback: move |_| rsx! {
|
||||||
|
"Loading {id}..."
|
||||||
|
},
|
||||||
|
Message { id }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn LoadTitle() -> Element {
|
||||||
|
let title = use_server_future(move || server_content(0))?()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
"title loaded"
|
||||||
|
document::Title { "{title.title}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn Message(id: usize) -> Element {
|
||||||
|
let message = use_server_future(move || server_content(id))?()
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
h2 {
|
||||||
|
id: "title-{id}",
|
||||||
|
"{message.title}"
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
id: "body-{id}",
|
||||||
|
"{message.body}"
|
||||||
|
}
|
||||||
|
div {
|
||||||
|
id: "children-{id}",
|
||||||
|
padding: "10px",
|
||||||
|
for child in message.children {
|
||||||
|
MessageWithLoader { id: child }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Content {
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
children: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server]
|
||||||
|
async fn server_content(id: usize) -> Result<Content, ServerFnError> {
|
||||||
|
let content_tree = [
|
||||||
|
Content {
|
||||||
|
title: "The robot says hello world".to_string(),
|
||||||
|
body: "The robot becomes sentient and says hello world".to_string(),
|
||||||
|
children: vec![1, 2, 3],
|
||||||
|
},
|
||||||
|
Content {
|
||||||
|
title: "The world says hello back".to_string(),
|
||||||
|
body: "In a stunning turn of events, the world collectively unites and says hello back"
|
||||||
|
.to_string(),
|
||||||
|
children: vec![4],
|
||||||
|
},
|
||||||
|
Content {
|
||||||
|
title: "Goodbye Robot".to_string(),
|
||||||
|
body: "The robot says goodbye".to_string(),
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
Content {
|
||||||
|
title: "Goodbye World".to_string(),
|
||||||
|
body: "The world says goodbye".to_string(),
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
Content {
|
||||||
|
title: "Hello World".to_string(),
|
||||||
|
body: "The world says hello again".to_string(),
|
||||||
|
children: vec![],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
||||||
|
Ok(content_tree[id].clone())
|
||||||
|
}
|
||||||
@@ -1,123 +1,12 @@
|
|||||||
// This test is used by playwright configured in the root of the repo
|
|
||||||
// Tests:
|
|
||||||
// - SEO without JS
|
|
||||||
// - Streaming hydration
|
|
||||||
// - Suspense
|
|
||||||
// - Server functions
|
|
||||||
//
|
|
||||||
// Without Javascript, content may not load into the right location, but it should still be somewhere in the html even if it is invisible
|
|
||||||
|
|
||||||
#![allow(non_snake_case)]
|
#![allow(non_snake_case)]
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use nested_suspense::app;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
LaunchBuilder::new()
|
LaunchBuilder::new()
|
||||||
.with_cfg(server_only! {
|
.with_cfg(server_only! {
|
||||||
dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming()
|
ServeConfig::builder()
|
||||||
|
.enable_out_of_order_streaming()
|
||||||
})
|
})
|
||||||
.launch(app);
|
.launch(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn app() -> Element {
|
|
||||||
rsx! {
|
|
||||||
SuspenseBoundary {
|
|
||||||
fallback: move |_| rsx! {},
|
|
||||||
document::Style {
|
|
||||||
href: asset!("/assets/style.css")
|
|
||||||
}
|
|
||||||
LoadTitle {}
|
|
||||||
}
|
|
||||||
MessageWithLoader { id: 0 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn MessageWithLoader(id: usize) -> Element {
|
|
||||||
rsx! {
|
|
||||||
SuspenseBoundary {
|
|
||||||
fallback: move |_| rsx! {
|
|
||||||
"Loading {id}..."
|
|
||||||
},
|
|
||||||
Message { id }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn LoadTitle() -> Element {
|
|
||||||
let title = use_server_future(move || server_content(0))?()
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
"title loaded"
|
|
||||||
document::Title { "{title.title}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[component]
|
|
||||||
fn Message(id: usize) -> Element {
|
|
||||||
let message = use_server_future(move || server_content(id))?()
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
rsx! {
|
|
||||||
h2 {
|
|
||||||
id: "title-{id}",
|
|
||||||
"{message.title}"
|
|
||||||
}
|
|
||||||
p {
|
|
||||||
id: "body-{id}",
|
|
||||||
"{message.body}"
|
|
||||||
}
|
|
||||||
div {
|
|
||||||
id: "children-{id}",
|
|
||||||
padding: "10px",
|
|
||||||
for child in message.children {
|
|
||||||
MessageWithLoader { id: child }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Serialize, Deserialize)]
|
|
||||||
pub struct Content {
|
|
||||||
title: String,
|
|
||||||
body: String,
|
|
||||||
children: Vec<usize>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[server]
|
|
||||||
async fn server_content(id: usize) -> Result<Content, ServerFnError> {
|
|
||||||
let content_tree = [
|
|
||||||
Content {
|
|
||||||
title: "The robot says hello world".to_string(),
|
|
||||||
body: "The robot becomes sentient and says hello world".to_string(),
|
|
||||||
children: vec![1, 2, 3],
|
|
||||||
},
|
|
||||||
Content {
|
|
||||||
title: "The world says hello back".to_string(),
|
|
||||||
body: "In a stunning turn of events, the world collectively unites and says hello back"
|
|
||||||
.to_string(),
|
|
||||||
children: vec![4],
|
|
||||||
},
|
|
||||||
Content {
|
|
||||||
title: "Goodbye Robot".to_string(),
|
|
||||||
body: "The robot says goodbye".to_string(),
|
|
||||||
children: vec![],
|
|
||||||
},
|
|
||||||
Content {
|
|
||||||
title: "Goodbye World".to_string(),
|
|
||||||
body: "The world says goodbye".to_string(),
|
|
||||||
children: vec![],
|
|
||||||
},
|
|
||||||
Content {
|
|
||||||
title: "Hello World".to_string(),
|
|
||||||
body: "The world says hello again".to_string(),
|
|
||||||
children: vec![],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
tokio::time::sleep(std::time::Duration::from_millis(1000)).await;
|
|
||||||
Ok(content_tree[id].clone())
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#![allow(non_snake_case)]
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use nested_suspense::app;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
dioxus::LaunchBuilder::new()
|
||||||
|
.with_cfg(server_only! {
|
||||||
|
ServeConfig::builder()
|
||||||
|
.incremental(
|
||||||
|
IncrementalRendererConfig::new()
|
||||||
|
.static_dir(
|
||||||
|
std::env::current_exe()
|
||||||
|
.unwrap()
|
||||||
|
.parent()
|
||||||
|
.unwrap()
|
||||||
|
.join("public")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.enable_out_of_order_streaming()
|
||||||
|
})
|
||||||
|
.launch(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[server(endpoint = "static_routes")]
|
||||||
|
async fn static_routes() -> Result<Vec<String>, ServerFnError> {
|
||||||
|
Ok(vec!["/".to_string()])
|
||||||
|
}
|
||||||
@@ -129,5 +129,14 @@ module.exports = defineConfig({
|
|||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
stdout: "pipe",
|
stdout: "pipe",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
cwd: path.join(process.cwd(), "nested-suspense"),
|
||||||
|
command:
|
||||||
|
'cargo run --package dioxus-cli --release -- serve --bin nested-suspense-ssg --force-sequential --platform web --ssg --addr "127.0.0.1" --port 6060',
|
||||||
|
port: 6060,
|
||||||
|
timeout: 50 * 60 * 1000,
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
stdout: "pipe",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user