diff --git a/packages/router-core/src/ssr/transformStreamWithRouter.ts b/packages/router-core/src/ssr/transformStreamWithRouter.ts index 8322b8da88..9dc91996c5 100644 --- a/packages/router-core/src/ssr/transformStreamWithRouter.ts +++ b/packages/router-core/src/ssr/transformStreamWithRouter.ts @@ -65,6 +65,7 @@ function createPassthrough(onCancel?: () => void) { res.destroyed = true }, destroy: (error) => { + res.destroyed = true controller.error(error) }, destroyed: false, @@ -81,8 +82,8 @@ async function readStream( onError?: (error: unknown) => void }, ) { + const reader = stream.getReader() try { - const reader = stream.getReader() let chunk while (!(chunk = await reader.read()).done) { opts.onData?.(chunk) @@ -90,6 +91,8 @@ async function readStream( opts.onEnd?.() } catch (error) { opts.onError?.(error) + } finally { + reader.releaseLock() } } @@ -112,10 +115,10 @@ export function transformStreamWithRouter( }) const textDecoder = new TextDecoder() - let isAppRendering = true as boolean + let isAppRendering = true let routerStreamBuffer = '' let pendingClosingTags = '' - let streamBarrierLifted = false as boolean + let streamBarrierLifted = false let leftover = '' let leftoverHtml = '' @@ -151,13 +154,22 @@ export function transformStreamWithRouter( promise .then((html) => { + // Don't write to destroyed stream + if (finalPassThrough.destroyed) { + return + } if (isAppRendering) { routerStreamBuffer += html } else { finalPassThrough.write(html) } }) - .catch(injectedHtmlDonePromise.reject) + .catch((err) => { + // Only reject if not already settled + if (!finalPassThrough.destroyed) { + injectedHtmlDonePromise.reject(err) + } + }) .finally(() => { processingCount-- @@ -173,7 +185,11 @@ export function transformStreamWithRouter( .then(() => { clearTimeout(timeoutHandle) const finalHtml = - leftoverHtml + getBufferedRouterStream() + pendingClosingTags + leftover + leftoverHtml + getBufferedRouterStream() + pendingClosingTags + + leftover = '' + leftoverHtml = '' + pendingClosingTags = '' finalPassThrough.end(finalHtml) }) @@ -217,15 +233,20 @@ export function transformStreamWithRouter( pendingClosingTags = chunkString.slice(bodyEndIndex) finalPassThrough.write( - chunkString.slice(0, bodyEndIndex) + getBufferedRouterStream(), + chunkString.slice(0, bodyEndIndex) + + getBufferedRouterStream() + + leftoverHtml, ) leftover = '' + leftoverHtml = '' return } let result: RegExpExecArray | null let lastIndex = 0 + // Reset regex lastIndex since it's global and stateful across exec() calls + patternClosingTag.lastIndex = 0 while ((result = patternClosingTag.exec(chunkString)) !== null) { lastIndex = result.index + result[0].length } @@ -238,12 +259,18 @@ export function transformStreamWithRouter( finalPassThrough.write(processed) leftover = chunkString.slice(lastIndex) + leftoverHtml = '' } else { leftover = chunkString leftoverHtml += getBufferedRouterStream() } }, onEnd: () => { + // Don't process if stream was already destroyed/cancelled + if (finalPassThrough.destroyed) { + return + } + // Mark the app as done rendering isAppRendering = false router.serverSsr!.setRenderFinished() @@ -262,6 +289,15 @@ export function transformStreamWithRouter( }, onError: (error) => { console.error('Error reading appStream:', error) + isAppRendering = false + router.serverSsr!.setRenderFinished() + // Clear timeout to prevent it from firing after error + clearTimeout(timeoutHandle) + // Clear string buffers to prevent memory leaks + leftover = '' + leftoverHtml = '' + routerStreamBuffer = '' + pendingClosingTags = '' finalPassThrough.destroy(error) injectedHtmlDonePromise.reject(error) },