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:
Evan Almloff authored and GitHub committed 2025-01-08 19:52:10 -08:00
1 parent 343c48f5ad
commit ecc419df0a
18 files changed
+370 -214

No files matched your search

@@ -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 = []
server = ["dioxus/server", "tokio"]
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)]
use dioxus::prelude::*;
use serde::{Deserialize, Serialize};
use nested_suspense::app;
fn main() {
LaunchBuilder::new()
.with_cfg(server_only! {
dioxus::fullstack::ServeConfig::builder().enable_out_of_order_streaming()
ServeConfig::builder()
.enable_out_of_order_streaming()
})
.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,
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",
},
],
});