Skip to content

fix(router-core): don't tear down SSR state while the response body is still streaming#7591

Open
radosek wants to merge 3 commits into
TanStack:mainfrom
radosek:fix/7529-ssr-query-stream-hang
Open

fix(router-core): don't tear down SSR state while the response body is still streaming#7591
radosek wants to merge 3 commits into
TanStack:mainfrom
radosek:fix/7529-ssr-query-stream-hang

Conversation

@radosek

@radosek radosek commented Jun 9, 2026

Copy link
Copy Markdown

Fixes #7529.

Deterministic reproduction (as requested)

Repo: https://github.com/radosek/tanstack-7529-repro — live demo (hangs on every request): https://tanstack-7529-repro.radosek.workers.dev/

Same app code, only the version changes:

@tanstack/react-router @tanstack/router-core result
1.169.0 1.169.0 response completes in ~3 ms
1.170.15 (latest) 1.171.13 (latest) every request hangs — shell + dehydration chunk arrive, </body></html> and the router module scripts never do, until the ~60 s serialization timeout

@tanstack/react-router-ssr-query@1.167.1 (latest) in both cases. The entry server is the body of v1.169.0's own renderRouterToStream (the renderToReadableStream branch), inlined verbatim: transformReadableStreamWithRouter(router, stream) + new Response(responseStream, …).

Root cause (instrumented trace against router-core@1.171.13)

1. onRenderFinished   registered OK (listeners=1)        ← ssr-query's close listener, during dehydrate()
2. reserveStreamFastPath  returns false                   ← transform takes the main stream path
3. cleanup()          wipes renderFinishedListeners=1,    ← createRequestHandler's `finally`,
                      serializationFinishedListeners=1       while the body is STILL STREAMING
4. ssr-query teardown closes the query stream             ← via onCleanup
5. setRenderFinished  finds listeners=0                   ← close listener is gone, completion signals destroyed

createRequestHandler changed semantics: in v1.169.0, once the callback was invoked the handler never cleaned up (cbWillCleanup = true before cb()), so a plain Response was safe. In v1.171.x, cleanup is deferred only when the callback's return value is wrapped by createSsrStreamResponse — an API that did not exist in v1.169.0 — so the same code now triggers cleanup() while the body is still streaming, wiping the listeners the stream transform waits on.

Fix

  • transformStreamWithRouter claims cleanup ownership when it attaches (serverSsr.claimCleanup()).
  • createRequestHandler skips its fallback cleanup when claimed. The transformed stream already calls serverSsr.cleanup() when consumed, cancelled, errored, or when its lifetime expires, so the transfer is leak-free.
  • The onRenderFinished fast-path guard relaxation from the original PR is kept (covered by its own unit test).

Verification

  • New regression test reproduces the production path (createRequestHandler + transformStreamWithRouter + plain Response): fails with a wiped-listener hang before the fix, passes after.
  • router-core suite: 1180 passed, 3 expected-fail, 0 type errors. react-router renderRouterToStream tests and router-ssr-query-core tests pass.
  • The reproduction app, patched with this fix against the broken combo (react-router@1.170.15), completes in ~3 ms instead of hanging.

Summary by CodeRabbit

  • Bug Fixes

    • SSR streaming: listeners added after the stream fast-path reservation now fire when the stream ends, and stream-owned cleanup is respected so fallback cleanup is skipped while streaming—preventing premature teardown and ensuring responses complete reliably.
  • Tests

    • Added a regression test verifying late-registered listeners run and cleanup occurs correctly in fast-path SSR streaming.

@coderabbitai

coderabbitai Bot commented Jun 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: eb67cd21-6a07-4d1c-a33c-01e87d6e9c40

📥 Commits

Reviewing files that changed from the base of the PR and between ea60ebe and 54c7bec.

📒 Files selected for processing (1)
  • .changeset/cool-streams-close.md

📝 Walkthrough

Walkthrough

Fixes an SSR streaming regression: onRenderFinished no longer drops listeners registered after the stream fast path is reserved; such listeners are retained and invoked (verified by a regression test and documented in a changeset).

Changes

SSR Listener Execution Fix

Layer / File(s) Summary
onRenderFinished behavior change
packages/router-core/src/ssr/ssr-server.ts
Stops early-returning when streamFastPathReserved is true; listeners registered after reservation are added so they run when setRenderFinished() executes.
Regression test for fast-path listener
packages/router-core/tests/ssr-server-cleanup.test.ts
Adds a test that reserves the stream fast path, registers an onRenderFinished listener afterwards, asserts it hasn't run immediately, then calls setRenderFinished() and verifies the listener runs exactly once, followed by cleanup.
Changeset
.changeset/cool-streams-close.md
Adds a changeset noting the patch-level fix: late-registered onRenderFinished listeners now run instead of being dropped, preventing SSR stream hangs and documenting cleanup ownership transferred to transformed streams.

Sequence Diagram(s)

sequenceDiagram
  participant SSRServer
  participant RouterSsrQueryIntegration
  participant QueryStream
  RouterSsrQueryIntegration->>SSRServer: reserveStreamFastPath()
  RouterSsrQueryIntegration->>SSRServer: onRenderFinished(register close callback)
  SSRServer->>QueryStream: setRenderFinished() (later)
  SSRServer->>RouterSsrQueryIntegration: invoke registered callback
  RouterSsrQueryIntegration->>QueryStream: close()
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • TanStack/router#7497: Related earlier streaming lifecycle changes touching createRequestHandler and ssr-server that overlap with this fix.

Suggested labels

package: router-core

Suggested reviewers

  • schiller-manuel

Poem

A rabbit found a stalled stream,
Where quiet listeners lost their gleam.
It nudged the fast path, gave a hop,
Now late listeners run — no stop. 🐇✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately reflects the main fix: preventing SSR state teardown while response body streams, addressing the core issue of premature cleanup.
Linked Issues check ✅ Passed The PR addresses all five objectives from #7529: identifies root cause of dropped listeners, restores listener behavior by removing streamFastPathReserved guard, ensures dehydration cleanup works, eliminates timeout hang, and maintains compatibility.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the SSR streaming regression: listener registration fix, cleanup ownership transfer via claimCleanup, and regression test verification.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@radosek radosek force-pushed the fix/7529-ssr-query-stream-hang branch from ae9d550 to ad107d2 Compare June 9, 2026 12:03
…th reserved

When the stream fast path was already reserved, onRenderFinished silently
dropped any listener registered afterwards. With @tanstack/react-router-ssr-query
this meant the dehydration query stream was never closed, hanging the SSR
response until the ~60s serialization timeout (TanStack#7529).

The fast path still calls setRenderFinished() once the app stream ends, so the
listener is now registered regardless and fires at the correct time instead of
being discarded.
@radosek radosek force-pushed the fix/7529-ssr-query-stream-hang branch from ad107d2 to ea60ebe Compare June 9, 2026 12:07

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/router-core/src/ssr/ssr-server.ts`:
- Line 640: The single-line guard "if (cleanupStarted) return" must use braces
to comply with the TypeScript coding guideline; locate the conditional using the
cleanupStarted variable (in the SSR shutdown/cleanup routine where
cleanupStarted is checked) and replace the single-line form with a braced block
(e.g., if (cleanupStarted) { return; }) so the control statement always uses
curly braces.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 265c1e99-50a2-427c-b3ff-819c14e0feca

📥 Commits

Reviewing files that changed from the base of the PR and between ae9d550 and ad107d2.

📒 Files selected for processing (3)
  • .changeset/cool-streams-close.md
  • packages/router-core/src/ssr/ssr-server.ts
  • packages/router-core/tests/ssr-server-cleanup.test.ts
✅ Files skipped from review due to trivial changes (1)
  • .changeset/cool-streams-close.md

Comment thread packages/router-core/src/ssr/ssr-server.ts
@schiller-manuel

Copy link
Copy Markdown
Collaborator

not happening until #7529 has a reproducer

radosek and others added 2 commits June 12, 2026 22:17
…s still streaming

When a handler callback returns a plain Response wrapping a stream
produced by transformStreamWithRouter — the contract of
renderRouterToStream up to v1.169.0 — createRequestHandler's finally
block ran serverSsr.cleanup() immediately, while the body was still
streaming. That wiped the render-finished and serialization-finished
listeners mid-flight: integrations like @tanstack/react-router-ssr-query
never closed their dehydration query stream, the transform never received
its completion signals, and the response hung until the ~60s
serialization timeout (TanStack#7529).

The stream transform now claims cleanup ownership when it attaches
(serverSsr.claimCleanup()), and createRequestHandler skips its fallback
cleanup when claimed. The transformed stream already cleans up when
consumed, cancelled, errored, or when its lifetime expires, so ownership
transfer is leak-free.

Regression test reproduces the production path: createRequestHandler +
transformStreamWithRouter + plain Response must complete and fire
onRenderFinished listeners (fails with a wiped-listener hang before this
change).
@radosek radosek changed the title fix(router-core): don't drop onRenderFinished listeners after stream fast path is reserved fix(router-core): don't tear down SSR state while the response body is still streaming Jun 12, 2026
@radosek

radosek commented Jun 12, 2026

Copy link
Copy Markdown
Author

Reproducer is up: https://github.com/radosek/tanstack-7529-repro (live: https://tanstack-7529-repro.radosek.workers.dev/ — hangs on every request on latest react-router + react-router-ssr-query, completes in ~3 ms on 1.169.0 with identical app code). Tracing it corrected the root cause — premature cleanup() from createRequestHandler's finally, not the fast-path guard — details in #7529 (comment). PR updated with the ownership fix + a regression test that reproduces the production path.

@schiller-manuel

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

router-core@1.171.7+: SSR query stream never closes with ssr-query → "Serialization timeout after app render finished"

2 participants