Skip to content

Commit 4d2fd33

Browse files
fix: more robust stream handling (TanStack#5996)
1 parent 09f2500 commit 4d2fd33

File tree

7 files changed

+136
-51
lines changed

7 files changed

+136
-51
lines changed

packages/react-router/src/ssr/renderRouterToStream.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,17 @@ export const renderRouterToStream = async ({
6161
}),
6262
onError: (error, info) => {
6363
console.error('Error in renderToPipeableStream:', error, info)
64+
// Destroy the passthrough stream on error
65+
if (!reactAppPassthrough.destroyed) {
66+
reactAppPassthrough.destroy(
67+
error instanceof Error ? error : new Error(String(error)),
68+
)
69+
}
6470
},
6571
})
6672
} catch (e) {
6773
console.error('Error in renderToPipeableStream:', e)
74+
reactAppPassthrough.destroy(e instanceof Error ? e : new Error(String(e)))
6875
}
6976

7077
const responseStream = transformPipeableStreamWithRouter(

packages/react-router/src/ssr/renderRouterToString.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,7 @@ export const renderRouterToString = async ({
2828
status: 500,
2929
headers: responseHeaders,
3030
})
31+
} finally {
32+
router.serverSsr?.cleanup()
3133
}
3234
}

packages/router-core/src/ssr/createRequestHandler.ts

Lines changed: 40 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -20,40 +20,54 @@ export function createRequestHandler<TRouter extends AnyRouter>({
2020
}): RequestHandler<TRouter> {
2121
return async (cb) => {
2222
const router = createRouter()
23+
// Track whether the callback will handle cleanup
24+
let cbWillCleanup = false
2325

24-
attachRouterServerSsrUtils({
25-
router,
26-
manifest: await getRouterManifest?.(),
27-
})
26+
try {
27+
attachRouterServerSsrUtils({
28+
router,
29+
manifest: await getRouterManifest?.(),
30+
})
2831

29-
const url = new URL(request.url, 'http://localhost')
30-
const origin = getOrigin(request)
31-
const href = url.href.replace(url.origin, '')
32+
const url = new URL(request.url, 'http://localhost')
33+
const origin = getOrigin(request)
34+
const href = url.href.replace(url.origin, '')
3235

33-
// Create a history for the router
34-
const history = createMemoryHistory({
35-
initialEntries: [href],
36-
})
36+
// Create a history for the router
37+
const history = createMemoryHistory({
38+
initialEntries: [href],
39+
})
3740

38-
// Update the router with the history and context
39-
router.update({
40-
history,
41-
origin: router.options.origin ?? origin,
42-
})
41+
// Update the router with the history and context
42+
router.update({
43+
history,
44+
origin: router.options.origin ?? origin,
45+
})
4346

44-
await router.load()
47+
await router.load()
4548

46-
await router.serverSsr?.dehydrate()
49+
await router.serverSsr?.dehydrate()
4750

48-
const responseHeaders = getRequestHeaders({
49-
router,
50-
})
51+
const responseHeaders = getRequestHeaders({
52+
router,
53+
})
5154

52-
return cb({
53-
request,
54-
router,
55-
responseHeaders,
56-
} as any)
55+
// Mark that the callback will handle cleanup
56+
cbWillCleanup = true
57+
return cb({
58+
request,
59+
router,
60+
responseHeaders,
61+
} as any)
62+
} finally {
63+
if (!cbWillCleanup) {
64+
// Clean up router SSR state if the callback won't handle it
65+
// (e.g., if an error occurred before the callback was invoked).
66+
// When the callback runs, it handles cleanup (either via transformStreamWithRouter
67+
// for streaming, or directly in renderRouterToString for non-streaming).
68+
router.serverSsr?.cleanup()
69+
}
70+
}
5771
}
5872
}
5973

packages/router-core/src/ssr/ssr-server.ts

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { Manifest, RouterManagedTag } from '../manifest'
1616
declare module '../router' {
1717
interface ServerSsr {
1818
setRenderFinished: () => void
19+
cleanup: () => void
1920
}
2021
interface RouterEvents {
2122
onInjectedHtml: {
@@ -55,11 +56,17 @@ const INITIAL_SCRIPTS = [
5556
]
5657

5758
class ScriptBuffer {
58-
constructor(private router: AnyRouter) {}
59+
private router: AnyRouter | undefined
5960
private _queue: Array<string> = [...INITIAL_SCRIPTS]
6061
private _scriptBarrierLifted = false
62+
private _cleanedUp = false
63+
64+
constructor(router: AnyRouter) {
65+
this.router = router
66+
}
6167

6268
enqueue(script: string) {
69+
if (this._cleanedUp) return
6370
if (this._scriptBarrierLifted && this._queue.length === 0) {
6471
queueMicrotask(() => {
6572
this.injectBufferedScripts()
@@ -69,7 +76,7 @@ class ScriptBuffer {
6976
}
7077

7178
liftBarrier() {
72-
if (this._scriptBarrierLifted) return
79+
if (this._scriptBarrierLifted || this._cleanedUp) return
7380
this._scriptBarrierLifted = true
7481
if (this._queue.length > 0) {
7582
queueMicrotask(() => {
@@ -90,11 +97,18 @@ class ScriptBuffer {
9097
}
9198

9299
injectBufferedScripts() {
100+
if (this._cleanedUp) return
93101
const scriptsToInject = this.takeAll()
94-
if (scriptsToInject) {
95-
this.router.serverSsr!.injectScript(() => scriptsToInject)
102+
if (scriptsToInject && this.router?.serverSsr) {
103+
this.router.serverSsr.injectScript(() => scriptsToInject)
96104
}
97105
}
106+
107+
cleanup() {
108+
this._cleanedUp = true
109+
this._queue = []
110+
this.router = undefined
111+
}
98112
}
99113

100114
export function attachRouterServerSsrUtils({
@@ -203,6 +217,8 @@ export function attachRouterServerSsrUtils({
203217
onRenderFinished: (listener) => listeners.push(listener),
204218
setRenderFinished: () => {
205219
listeners.forEach((l) => l())
220+
// Clear listeners after calling them to prevent memory leaks
221+
listeners.length = 0
206222
scriptBuffer.liftBarrier()
207223
},
208224
takeBufferedScripts() {
@@ -221,6 +237,14 @@ export function attachRouterServerSsrUtils({
221237
liftScriptBarrier() {
222238
scriptBuffer.liftBarrier()
223239
},
240+
cleanup() {
241+
// Guard against multiple cleanup calls
242+
if (!router.serverSsr) return
243+
listeners.length = 0
244+
scriptBuffer.cleanup()
245+
router.serverSsr.injectedHtml = []
246+
router.serverSsr = undefined
247+
},
224248
}
225249
}
226250

packages/router-core/src/ssr/transformStreamWithRouter.ts

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ type ReadablePassthrough = {
3535
destroyed: boolean
3636
}
3737

38-
function createPassthrough(onCancel?: () => void) {
38+
function createPassthrough(onCancel: () => void) {
3939
let controller: ReadableStreamDefaultController<any>
4040
const encoder = new TextEncoder()
4141
const stream = new ReadableStream({
@@ -44,27 +44,33 @@ function createPassthrough(onCancel?: () => void) {
4444
},
4545
cancel() {
4646
res.destroyed = true
47-
onCancel?.()
47+
onCancel()
4848
},
4949
})
5050

5151
const res: ReadablePassthrough = {
5252
stream,
5353
write: (chunk) => {
54+
// Don't write to destroyed stream
55+
if (res.destroyed) return
5456
if (typeof chunk === 'string') {
5557
controller.enqueue(encoder.encode(chunk))
5658
} else {
5759
controller.enqueue(chunk)
5860
}
5961
},
6062
end: (chunk) => {
63+
// Don't end already destroyed stream
64+
if (res.destroyed) return
6165
if (chunk) {
6266
res.write(chunk)
6367
}
64-
controller.close()
6568
res.destroyed = true
69+
controller.close()
6670
},
6771
destroy: (error) => {
72+
// Don't destroy already destroyed stream
73+
if (res.destroyed) return
6874
res.destroyed = true
6975
controller.error(error)
7076
},
@@ -105,14 +111,20 @@ export function transformStreamWithRouter(
105111
) {
106112
let stopListeningToInjectedHtml: (() => void) | undefined = undefined
107113
let timeoutHandle: NodeJS.Timeout
114+
let cleanedUp = false
108115

109-
const finalPassThrough = createPassthrough(() => {
116+
function cleanup() {
117+
if (cleanedUp) return
118+
cleanedUp = true
110119
if (stopListeningToInjectedHtml) {
111120
stopListeningToInjectedHtml()
112121
stopListeningToInjectedHtml = undefined
113122
}
114123
clearTimeout(timeoutHandle)
115-
})
124+
router.serverSsr?.cleanup()
125+
}
126+
127+
const finalPassThrough = createPassthrough(cleanup)
116128
const textDecoder = new TextDecoder()
117129

118130
let isAppRendering = true
@@ -149,13 +161,16 @@ export function transformStreamWithRouter(
149161
)
150162

151163
function handleInjectedHtml() {
164+
// Don't process if already cleaned up
165+
if (cleanedUp) return
166+
152167
router.serverSsr!.injectedHtml.forEach((promise) => {
153168
processingCount++
154169

155170
promise
156171
.then((html) => {
157-
// Don't write to destroyed stream
158-
if (finalPassThrough.destroyed) {
172+
// Don't write to destroyed stream or after cleanup
173+
if (cleanedUp || finalPassThrough.destroyed) {
159174
return
160175
}
161176
if (isAppRendering) {
@@ -165,10 +180,7 @@ export function transformStreamWithRouter(
165180
}
166181
})
167182
.catch((err) => {
168-
// Only reject if not already settled
169-
if (!finalPassThrough.destroyed) {
170-
injectedHtmlDonePromise.reject(err)
171-
}
183+
injectedHtmlDonePromise.reject(err)
172184
})
173185
.finally(() => {
174186
processingCount--
@@ -183,6 +195,11 @@ export function transformStreamWithRouter(
183195

184196
injectedHtmlDonePromise
185197
.then(() => {
198+
// Don't process if already cleaned up or destroyed
199+
if (cleanedUp || finalPassThrough.destroyed) {
200+
return
201+
}
202+
186203
clearTimeout(timeoutHandle)
187204
const finalHtml =
188205
leftover + leftoverHtml + getBufferedRouterStream() + pendingClosingTags
@@ -194,19 +211,24 @@ export function transformStreamWithRouter(
194211
finalPassThrough.end(finalHtml)
195212
})
196213
.catch((err) => {
214+
// Don't process if already cleaned up
215+
if (cleanedUp || finalPassThrough.destroyed) {
216+
return
217+
}
218+
197219
console.error('Error reading routerStream:', err)
198220
finalPassThrough.destroy(err)
199221
})
200-
.finally(() => {
201-
if (stopListeningToInjectedHtml) {
202-
stopListeningToInjectedHtml()
203-
stopListeningToInjectedHtml = undefined
204-
}
205-
})
222+
.finally(cleanup)
206223

207224
// Transform the appStream
208225
readStream(appStream, {
209226
onData: (chunk) => {
227+
// Don't process if already cleaned up
228+
if (cleanedUp || finalPassThrough.destroyed) {
229+
return
230+
}
231+
210232
const text = decodeChunk(chunk.value)
211233
const chunkString = leftover + text
212234
const bodyEndMatch = chunkString.match(patternBodyEnd)
@@ -266,8 +288,8 @@ export function transformStreamWithRouter(
266288
}
267289
},
268290
onEnd: () => {
269-
// Don't process if stream was already destroyed/cancelled
270-
if (finalPassThrough.destroyed) {
291+
// Don't process if stream was already destroyed/cancelled or cleaned up
292+
if (cleanedUp || finalPassThrough.destroyed) {
271293
return
272294
}
273295

@@ -288,6 +310,11 @@ export function transformStreamWithRouter(
288310
}
289311
},
290312
onError: (error) => {
313+
// Don't process if already cleaned up
314+
if (cleanedUp) {
315+
return
316+
}
317+
291318
console.error('Error reading appStream:', error)
292319
isAppRendering = false
293320
router.serverSsr!.setRenderFinished()

packages/solid-router/src/ssr/renderRouterToString.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,5 +40,7 @@ export const renderRouterToString = async ({
4040
status: 500,
4141
headers: responseHeaders,
4242
})
43+
} finally {
44+
router.serverSsr?.cleanup()
4345
}
4446
}

packages/start-server-core/src/createStartHandler.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,8 @@ export function createStartHandler<TRegister = Register>(
8181
requestOpts,
8282
) => {
8383
let router: AnyRouter | null = null as AnyRouter | null
84+
// Track whether the callback will handle cleanup
85+
let cbWillCleanup = false as boolean
8486
try {
8587
const origin = getOrigin(request)
8688

@@ -207,6 +209,8 @@ export function createStartHandler<TRegister = Register>(
207209
await router.serverSsr!.dehydrate()
208210

209211
const responseHeaders = getStartResponseHeaders({ router })
212+
// Mark that the callback will handle cleanup
213+
cbWillCleanup = true
210214
const response = await cb({
211215
request,
212216
router,
@@ -312,9 +316,14 @@ export function createStartHandler<TRegister = Register>(
312316

313317
return response
314318
} finally {
315-
if (router) {
316-
router = null
319+
if (router && !cbWillCleanup) {
320+
// Clean up router SSR state if it was set up but won't be cleaned up by the callback
321+
// (e.g., in redirect cases or early returns before the callback is invoked).
322+
// When the callback runs, it handles cleanup (either via transformStreamWithRouter
323+
// for streaming, or directly in renderRouterToString for non-streaming).
324+
router.serverSsr?.cleanup()
317325
}
326+
router = null
318327
}
319328
}
320329

0 commit comments

Comments
 (0)