fix(router-core): don't tear down SSR state while the response body is still streaming#7591
fix(router-core): don't tear down SSR state while the response body is still streaming#7591radosek wants to merge 3 commits into
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughFixes 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). ChangesSSR Listener Execution Fix
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()
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
ae9d550 to
ad107d2
Compare
…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.
ad107d2 to
ea60ebe
Compare
There was a problem hiding this comment.
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
📒 Files selected for processing (3)
.changeset/cool-streams-close.mdpackages/router-core/src/ssr/ssr-server.tspackages/router-core/tests/ssr-server-cleanup.test.ts
✅ Files skipped from review due to trivial changes (1)
- .changeset/cool-streams-close.md
|
not happening until #7529 has a reproducer |
…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).
|
Reproducer is up: https://github.com/radosek/tanstack-7529-repro (live: https://tanstack-7529-repro.radosek.workers.dev/ — hangs on every request on latest |
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-core1.169.01.169.01.170.15(latest)1.171.13(latest)</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 ofv1.169.0's ownrenderRouterToStream(therenderToReadableStreambranch), inlined verbatim:transformReadableStreamWithRouter(router, stream)+new Response(responseStream, …).Root cause (instrumented trace against
router-core@1.171.13)createRequestHandlerchanged semantics: inv1.169.0, once the callback was invoked the handler never cleaned up (cbWillCleanup = truebeforecb()), so a plainResponsewas safe. Inv1.171.x, cleanup is deferred only when the callback's return value is wrapped bycreateSsrStreamResponse— an API that did not exist inv1.169.0— so the same code now triggerscleanup()while the body is still streaming, wiping the listeners the stream transform waits on.Fix
transformStreamWithRouterclaims cleanup ownership when it attaches (serverSsr.claimCleanup()).createRequestHandlerskips its fallback cleanup when claimed. The transformed stream already callsserverSsr.cleanup()when consumed, cancelled, errored, or when its lifetime expires, so the transfer is leak-free.onRenderFinishedfast-path guard relaxation from the original PR is kept (covered by its own unit test).Verification
createRequestHandler+transformStreamWithRouter+ plainResponse): fails with a wiped-listener hang before the fix, passes after.router-coresuite: 1180 passed, 3 expected-fail, 0 type errors.react-routerrenderRouterToStreamtests androuter-ssr-query-coretests pass.react-router@1.170.15), completes in ~3 ms instead of hanging.Summary by CodeRabbit
Bug Fixes
Tests