yewstack / yew

Rust / Wasm framework for creating reliable and efficient web applications
https://yew.rs
Apache License 2.0
30.61k stars 1.42k forks source link

SSR + hydration results in a stalled request #3619

Open christobill opened 7 months ago

christobill commented 7 months ago

Problem When building a small SSR + hydration web app and opening in the browser we get a part of the html but we have to wait the end of the server-side request to have the rest of the html. The request is stalled, there is no hydration nor fallback UI (they are configured, though)

Steps To Reproduce Starting from the example ssr_router, filling src/lib.rs and using App and ServerApp in the other files:

use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use uuid::Uuid;
use yew::html::RenderError;
use yew::prelude::*;
use yew_router::history::{AnyHistory, History, MemoryHistory};
use yew_router::prelude::*;

#[derive(Debug, Clone, Copy, PartialEq, Routable)]
enum Route {
    #[at("/")]
    Home,
    #[at("/hi")]
    Hi,
}

#[cfg(target_arch = "wasm32")]
async fn async_delay<F>(f: F, duration: u64)
where
    F: FnOnce() + 'static,
{
    use gloo_timers::future::TimeoutFuture;
    TimeoutFuture::new(duration as u32).await;
    f()
}

#[cfg(not(target_arch = "wasm32"))]
async fn async_delay<F>(f: F, duration: u64)
where
    F: FnOnce() + 'static,
{
    use tokio::time::{sleep, Duration};
    sleep(Duration::from_millis(duration)).await;
    f()
}

#[derive(Serialize, Deserialize)]
struct UuidResponse {
    uuid: Uuid,
}

#[cfg(feature = "ssr")]
async fn fetch_uuid() -> Uuid {
    // reqwest works for both non-wasm and wasm targets.
    let resp = reqwest::get("https://httpbin.org/uuid").await.unwrap();
    async_delay(|| (), 30000).await;
    let uuid_resp = resp.json::<UuidResponse>().await.unwrap();

    uuid_resp.uuid
}

#[function_component]
fn Nav() -> Html {
    html! {
        <ul>
            <li><Link<Route> to={Route::Home}>{"Home"}</Link<Route>></li>
            <li><Link<Route> to={Route::Hi}>{"Hi"}</Link<Route>></li>
        </ul>
    }
}

#[function_component]
fn Content() -> HtmlResult {
    let uuid =
        use_prepared_state!((), async move |_| -> Uuid { fetch_uuid().await }).map_err(|e| {
            println!("error: {:#?}", e);
            e
        });

    let uuid2 = (match uuid {
        Ok(Some(x)) => Ok(html! {
            <div>{"Random UUID: "}{x}</div>
        }),
        Ok(None) => Ok(html! {
            <div>{"Loading"}</div>
        }),
        Err(e) => Err(RenderError::Suspended(e)),
    });

    uuid2
}

#[function_component]
fn Hi() -> HtmlResult {
    Ok(html! {
        <div>{"Hi"}</div>
    })
}

#[derive(Properties, PartialEq, Eq, Debug)]
pub struct ServerAppProps {
    pub url: AttrValue,
    pub queries: HashMap<String, String>,
}

#[function_component]
pub fn ServerApp(props: &ServerAppProps) -> Html {
    let history = AnyHistory::from(MemoryHistory::new());
    history
        .push_with_query(&*props.url, &props.queries)
        .unwrap();

    html! {
        <Router history={history}>
            <Nav />
            <Switch<Route> render={switch} />
        </Router>
    }
}

#[function_component]
pub fn App() -> Html {
    html! {
        <BrowserRouter>
            <Nav />
            <Switch<Route> render={switch} />
        </BrowserRouter>
    }
}

fn switch(routes: Route) -> Html {
    match routes {
        Route::Hi => html! {<Hi />},
        Route::Home => {
            let fallback = html! {<div>{"Loading..."}</div>};
            html! {
                <Suspense {fallback}><Content /></Suspense>
            }
        }
    }
}

and src/bin/ssr_router_server

use std::collections::HashMap;
use std::convert::Infallible;
use std::path::PathBuf;

use axum::body::{Body, StreamBody};
use axum::error_handling::HandleError;
use axum::extract::Query;
use axum::handler::Handler;
use axum::http::{Request, StatusCode};
use axum::response::IntoResponse;
use axum::routing::get;
use axum::{Extension, Router};
use clap::Parser;
use futures::stream::{self, StreamExt};
use hyper::server::Server;
use tower::ServiceExt;
use tower_http::services::ServeDir;
use yew::ServerRenderer;

use simple_ssr::{ServerApp, ServerAppProps};

/// A basic example
#[derive(Parser, Debug)]
struct Opt {
    /// the "dist" created by trunk directory to be served for hydration.
    #[structopt(short, long, parse(from_os_str))]
    dir: PathBuf,
}

async fn render(
    Extension((index_html_before, index_html_after)): Extension<(String, String)>,
    url: Request<Body>,
    Query(queries): Query<HashMap<String, String>>,
) -> impl IntoResponse {
    let url = url.uri().to_string();

    let renderer = ServerRenderer::<ServerApp>::with_props(move || ServerAppProps {
        url: url.into(),
        queries,
    });

    StreamBody::new(
        stream::once(async move { index_html_before })
            .chain(renderer.render_stream())
            .chain(stream::once(async move { index_html_after }))
            .map(Result::<_, Infallible>::Ok),
    )
}

#[tokio::main]
async fn main() {
    let opts = Opt::parse();

    let index_html_s = tokio::fs::read_to_string(opts.dir.join("index.html"))
        .await
        .expect("failed to read index.html");

    let (index_html_before, index_html_after) = index_html_s.split_once("<body>").unwrap();
    let mut index_html_before = index_html_before.to_owned();
    index_html_before.push_str("<body>");
    let index_html_after = index_html_after.to_owned();

    let handle_error = |e| async move {
        (
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("error occurred: {}", e),
        )
    };

    let app = Router::new()
        .route("/api/test", get(|| async move { "Hello World" }))
        .fallback(HandleError::new(
            ServeDir::new(opts.dir)
                .append_index_html_on_directories(false)
                .fallback(
                    render
                        .layer(Extension((
                            index_html_before.clone(),
                            index_html_after.clone(),
                        )))
                        .into_service()
                        .map_err(|err| -> std::io::Error { match err {} }),
                ),
            handle_error,
        ));

    println!("You can view the website at: http://localhost:8081/");
    Server::bind(&"127.0.0.1:8081".parse().unwrap())
        .serve(app.into_make_service())
        .await
        .unwrap();
}

Launch the server: trunk build --release index.html && cargo run --release --features=ssr --bin ssr_router_server -- --dir=dist

Go to the url http://localhost:8081 and wait 30 seconds for the request to https://httpbin.org/uuid to complete: You will see part of the UI, but no fallback UI. If you curl "http://localhost:8081", you will have to wait 30 seconds to get an answer

Expected behavior Server side rendering should display <div>Loading...</div> and hydration should hydrate with the request result: <div>Random UUID: 1be9a2ec-c62e-40af-9b38-9fccae823f84</div>

Environment:

Questionnaire