#3280·leptos

Blocking resources: `ResponseOptions` are lost / nested blocking resources don't block but stream.

Author: zakstuckeCreated Nov 23, 2024Updated Jun 12, 2026
Labelsbug

Describe the bug Nested blocking resources inside suspense don't end up blocking.

This becomes obvious when setting things to ResponseOptions in the nested resource and those values don't make it to the client, I believe because they end up streaming after the response is sent, instead of blocking correctly.

I've used OnceResource's in the repro, but applies to normal Resource::new_blocking too.

Leptos Dependencies Rc2

To Reproduce I've got a repro working from the server_fns_axum example, specific repro in codeblock below, but you can just checkout my fork and run that example: https://github.com/zakstucke/leptos/tree/suspense-bug

  • cargo leptos watch
  • Click the "Check cookie" button, you'll see no cookie set (broken)
  • Replace the <App /> body with view! { <InnerComponent /> }, removing the outer suspense/blocking resource, retry and you'll see the cookie is now being correctly set.

Broken with nested suspense/blocking resource:

https://github.com/user-attachments/assets/3d22cf2f-2772-4e9c-8ab3-bc178b1a72eb

Working after removing outer suspense/blocking resource:

https://github.com/user-attachments/assets/5082581b-9106-483c-9992-1233e6449d91

rust
#[component]
pub fn App() -> impl IntoView {
    let outer_r = OnceResource::new_blocking(async move {
        #[cfg(feature = "ssr")]
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        Ok::<_, ()>(())
    });

    let inner_view = move || {
        Suspend::new(async move {
            let _ = outer_r.await;

            view! { <InnerComponent /> }
        })
    };

    view! {
        <main>
            <Suspense>{inner_view}</Suspense>
        </main>
    }

    // FOR WORKING CASE, REPLACE THIS APP BODY WITH: `view! { <InnerComponent /> }`
}

#[component]
pub fn InnerComponent() -> impl IntoView {
    let inner_r = OnceResource::new_blocking(async move {
        let cookie_name =
            uuid::Uuid::new_v4().to_string().replace("-", "")[0..5].to_string();
        let cookie_value =
            uuid::Uuid::new_v4().to_string().replace("-", "")[0..5].to_string();

        #[cfg(feature = "ssr")]
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        let _ = s_set_cookie(cookie_name.clone(), cookie_value.clone()).await;
        (cookie_name, cookie_value)
    });

    let getter = Action::new(move |(name,): &(String,)| {
        let name = name.clone();
        async move {
            s_get_cookie(name)
                .await
                .unwrap()
                .unwrap_or_else(|| "NO COOKIE SET".to_string())
        }
    });

    view! {
        <Suspense>
            {move || {
                Suspend::new({
                    async move {
                        let (name, value) = inner_r.await;
                        let name = StoredValue::new(name);
                        let value = StoredValue::new(value);
                        view! {
                            <div>
                                <button
                                    type="button"
                                    on:click=move |_e| {
                                        getter.dispatch((name.get_value(),));
                                    }
                                >
                                    {"Check cookie"}
                                </button>
                                <p>
                                    {move || {
                                        view! {
                                            {format!(
                                                "Expecting cookie of {}={}",
                                                name.get_value(),
                                                value.get_value(),
                                            )}
                                            <br />
                                            {format!(
                                                "cookie: {}",
                                                getter
                                                    .value()
                                                    .get()
                                                    .unwrap_or_else(|| {
                                                        "click 'Check cookie' to see".to_string()
                                                    }),
                                            )}
                                        }
                                    }}
                                </p>
                            </div>
                        }
                    }
                })
            }}
        </Suspense>
    }
}

#[server]
pub async fn s_set_cookie(
    name: String,
    value: String,
) -> Result<(), ServerFnError> {
    if let Some(opts) = use_context::<leptos_axum::ResponseOptions>() {
        opts.insert_header(
            http::header::SET_COOKIE,
            http::header::HeaderValue::from_str(&format!("{}={}", name, value))
                .unwrap(),
        )
    } else {
        println!("No response options found");
    }
    Ok(())
}

#[server]
pub async fn s_get_cookie(
    name: String,
) -> Result<Option<String>, ServerFnError> {
    if let Some(parts) = leptos::prelude::use_context::<http::request::Parts>()
    {
        let cookies = axum_extra::extract::cookie::CookieJar::from_headers(
            &parts.headers,
        );
        println!("COOKIES: {:#?}", cookies);
        let found = cookies.get(&name).map(|cookie| cookie.value().to_string());
        println!("For name: {}, FOUND: {:#?}", name, found);
        Ok(found)
    } else {
        println!("No request parts found");
        Ok(None)
    }
}