#4773·leptos

SSR render hangs forever when a `Resource` first polls during a concurrent `RouteList::generate` (process-global `SuppressResourceLoad`)

Author: AlexeyMatskevichCreated Jun 10, 2026Updated Jun 12, 2026
Labelsneeds reproduction

Describe the bug

If a Resource's future is first polled while any other thread in the process is inside RouteList::generate (route-list generation / SSG), that resource — and the SSR render awaiting it — hangs forever, even though the generation window closes shortly afterwards.

Mechanism (all in released crates):

  • IS_SUPPRESSING_RESOURCE_LOAD is a process-global AtomicBool in leptos_server (resource.rs:37), set/cleared by the RAII guard SuppressResourceLoad (resource.rs:42-60).

  • A resource's fetcher wrapper checks the flag once, when its future is first polled, and commits to the branch for good (resource.rs:313-323):

    rust
    async move {
        if IS_SUPPRESSING_RESOURCE_LOAD.load(Ordering::Relaxed) {
            pending().await
        } else {
            fut.await
        }
    }

    pending() never wakes, and the flag is never re-checked — so a resource that lands in this branch is permanently stuck even after the guard is dropped.

  • RouteList::generate takes that guard around running the whole app once (generate_route_list.rs:237). The suppression is correct for the generation run itself, but because the flag is process-global it also poisons every unrelated SSR render running concurrently in the same process whose resource first-polls inside the window.

Any server that calls generate_route_list* while it can also be rendering (on-demand SSG regeneration, route-list generation at startup overlapping early traffic, or a test suite running generation and renders in one process) can permanently hang those renders. The hang is timing-dependent, so in practice it shows up as rare, hard-to-diagnose stuck requests / flaky CI.

Leptos Dependencies

toml
leptos = { version = "0.8.19", features = ["ssr"] }
# harness only:
any_spawner = { version = "0.3", features = ["tokio"] }
hydration_context = "0.3"
futures = "0.3"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }

(leptos_server resolves to 0.8.7. No integration crate involved — this reproduces with leptos alone.)

To Reproduce

The thread spawned below does exactly what RouteList::generate does internally (takes SuppressResourceLoad for the duration of a run); it could equally be a real generate_route_list_with_ssg call running on another thread:

rust
#[cfg(test)]
mod tests {
    use futures::StreamExt;
    use leptos::prelude::*;

    #[tokio::test]
    async fn suppression_window_permanently_hangs_render() {
        _ = any_spawner::Executor::init_tokio();

        // What any concurrent `generate_route_list` / `RouteList::generate`
        // call does internally (leptos_router/src/generate_route_list.rs):
        std::thread::spawn(|| {
            let _g = leptos::server::SuppressResourceLoad::new();
            std::thread::sleep(std::time::Duration::from_secs(1));
        });
        std::thread::sleep(std::time::Duration::from_millis(100));

        let owner = Owner::new_root(Some(std::sync::Arc::new(
            hydration_context::SsrSharedContext::new(),
        )));
        let stream = owner.with(|| {
            view! {
                <Suspense fallback=|| "loading">
                    {move || Suspend::new(async move {
                        let res = Resource::new(|| (), |_| async { 42 });
                        res.await
                    })}
                </Suspense>
            }
            .to_html_stream_in_order()
        });

        // The suppression guard above is dropped after 1s, but the resource
        // committed to `pending().await` at its first poll: hangs forever.
        tokio::time::timeout(
            std::time::Duration::from_secs(5),
            stream.collect::<String>(),
        )
        .await
        .expect("SSR render permanently hung by a concurrent SuppressResourceLoad window");
    }

    #[tokio::test]
    async fn control_without_suppression_completes() {
        _ = any_spawner::Executor::init_tokio();
        let owner = Owner::new_root(Some(std::sync::Arc::new(
            hydration_context::SsrSharedContext::new(),
        )));
        let stream = owner.with(|| {
            view! {
                <Suspense fallback=|| "loading">
                    {move || Suspend::new(async move {
                        let res = Resource::new(|| (), |_| async { 42 });
                        res.await
                    })}
                </Suspense>
            }
            .to_html_stream_in_order()
        });
        let html = tokio::time::timeout(
            std::time::Duration::from_secs(5),
            stream.collect::<String>(),
        )
        .await
        .expect("control render must complete");
        assert!(html.contains("42"), "{html}");
    }
}

Observed (macOS, leptos 0.8.19):

  1. cargo test control_without → passes in 0.00s.

  2. cargo test suppression_window → render outlives the 1-second suppression window and is still stuck at the 5-second timeout:

    thread 'tests::suppression_window_permanently_hangs_render' panicked:
    SSR render permanently hung by a concurrent SuppressResourceLoad window: Elapsed(())
  3. Incidentally, plain cargo test (both tests in parallel) makes the control test fail too — the global flag from one test poisons the unrelated render in the other, which is the blast radius this issue is about, demonstrated within one process.

Screenshots

n/a — see test output above.

Next Steps

  • I will make a PR
  • I would like to make a PR, but need help getting started
  • I want someone else to take the time to fix this
  • This is a low priority for me and is just shared for your information

Additional context

Originally diagnosed while investigating rare hangs of SsrMode::InOrder/Async suspense renders in the test suite of an ntex integration port (leptos_ntex), where generate_route_list_with_ssg calls overlap SSR renders in one process. The executor/integration was ruled out: the minimal reproduction above uses leptos only.

Possible directions, in case they help triage:

  • have the suppressed branch await a wakeable signal (e.g. a watch/Notify flipped by SuppressResourceLoad::drop) and re-check the flag, instead of committing to pending().await forever;
  • or scope suppression to the generating owner/reactive graph (e.g. a context or task-local) instead of a process-global flag;
  • or, at minimum, document that RouteList::generate / generate_route_list* must not overlap any SSR render in the same process.

Not related to #4749 (different bug, same general SSG area).