From 438ac0d45d3963ddf4ed4ce48155826ae97621a9 Mon Sep 17 00:00:00 2001 From: James Date: Mon, 9 Mar 2026 23:18:20 +0000 Subject: [PATCH 1/2] perf: tee stream for ISR caching instead of full re-render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On ISR cache misses, the dev server and pages-server-entry were doing a complete second renderToStringAsync of the same page just to get a string for the cache — throwing away the HTML already produced by the first render. Fix: use ReadableStream.tee() to split the body stream at render time. One branch is piped to the client; the other is collected in memory. The full HTML is assembled from the collected chunks and written to the ISR cache — eliminating the extra render entirely. - dev-server.ts: streamPageToResponse() gains a collectHtml option that tees bodyStream, pipes one branch to the response, collects the other, and returns the assembled HTML string to the caller. - pages-server-entry.ts: compositeStream (prefix + body + suffix) is teed before the Response is returned; the cache branch is drained in a background async IIFE so it does not block the streaming response. --- .../vinext/src/entries/pages-server-entry.ts | 42 ++++++--- packages/vinext/src/server/dev-server.ts | 89 ++++++++++++++----- 2 files changed, 97 insertions(+), 34 deletions(-) diff --git a/packages/vinext/src/entries/pages-server-entry.ts b/packages/vinext/src/entries/pages-server-entry.ts index 5bd5d432..dd43bb7e 100644 --- a/packages/vinext/src/entries/pages-server-entry.ts +++ b/packages/vinext/src/entries/pages-server-entry.ts @@ -899,22 +899,36 @@ export async function renderPage(request, url, manifest) { } }); - // Cache the rendered HTML for ISR (needs the full string — re-render synchronously) + // Cache the rendered HTML for ISR. + // Tee the composite stream so one branch is returned to the caller + // (streamed to the client) while the other is collected in the + // background for the ISR cache. No second render is required. + var responseStream = compositeStream; if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { - // Tee the stream so we can cache and respond simultaneously would be ideal, - // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. - var isrElement; - if (AppComponent) { - isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); - } else { - isrElement = React.createElement(PageComponent, pageProps); - } - isrElement = wrapWithRouterContext(isrElement); - var isrHtml = await renderToStringAsync(isrElement); - var fullHtml = shellPrefix + isrHtml + shellSuffix; + var [responseStream, cacheStream] = compositeStream.tee(); var isrPathname = url.split("?")[0]; var isrCacheKey = "pages:" + (isrPathname === "/" ? "/" : isrPathname.replace(/\\/$/, "")); - await isrSet(isrCacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds); + // Collect the cache branch and write to ISR after the stream drains. + // This runs in the background and does NOT block the response. + (async () => { + var decoder = new TextDecoder(); + var cacheChunks = []; + var cacheReader = cacheStream.getReader(); + try { + for (;;) { + var chunk = await cacheReader.read(); + if (chunk.done) break; + cacheChunks.push(decoder.decode(chunk.value, { stream: true })); + } + cacheChunks.push(decoder.decode()); + } finally { + cacheReader.releaseLock(); + } + var fullHtml = cacheChunks.join(""); + await isrSet(isrCacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds); + })().catch((err) => { + console.error("[vinext] ISR cache write failed:", err); + }); } // Merge headers/status/cookies set by getServerSideProps on the res object. @@ -943,7 +957,7 @@ export async function renderPage(request, url, manifest) { if (_fontLinkHeader) { responseHeaders.set("Link", _fontLinkHeader); } - return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); + return new Response(responseStream, { status: finalStatus, headers: responseHeaders }); } catch (e) { console.error("[vinext] SSR error:", e); return new Response("Internal Server Error", { status: 500 }); diff --git a/packages/vinext/src/server/dev-server.ts b/packages/vinext/src/server/dev-server.ts index f703be18..24bfdde0 100644 --- a/packages/vinext/src/server/dev-server.ts +++ b/packages/vinext/src/server/dev-server.ts @@ -58,6 +58,11 @@ const STREAM_BODY_MARKER = ""; * stream completes (the data is known before rendering starts, but * deferring them reduces TTFB and lets the browser start parsing the * shell sooner). + * + * When `collectHtml` is true, the body stream is teed so one branch is + * piped to the client while the other is collected in memory. The + * function returns the full HTML string (prefix + body + suffix) so the + * caller can use it for ISR caching without a second render. */ async function streamPageToResponse( res: ServerResponse, @@ -72,8 +77,10 @@ async function streamPageToResponse( extraHeaders?: Record; /** Called after renderToReadableStream resolves (shell ready) to collect head HTML */ getHeadHTML: () => string; + /** When true, tee the body stream and return the full collected HTML for ISR caching. */ + collectHtml?: boolean; }, -): Promise { +): Promise { const { url, server, @@ -83,6 +90,7 @@ async function streamPageToResponse( statusCode = 200, extraHeaders, getHeadHTML, + collectHtml = false, } = options; // Start the React body stream FIRST — the promise resolves when the @@ -155,6 +163,53 @@ async function streamPageToResponse( // Write the document prefix (head, opening body) res.write(prefix); + if (collectHtml) { + // Tee the body stream: one branch goes to the client, the other is + // collected for ISR caching. Both consumers see identical bytes, so + // there is no second render — this eliminates the double-render cost. + const [clientBranch, cacheBranch] = bodyStream.tee(); + const decoder = new TextDecoder(); + const cacheChunks: string[] = []; + + // Collect the cache branch concurrently while the client branch is + // being piped to the response below. We don't await here so that + // collection runs in parallel with the client write loop. + const collectPromise = (async () => { + const cacheReader = cacheBranch.getReader(); + try { + for (;;) { + const { done, value } = await cacheReader.read(); + if (done) break; + cacheChunks.push(decoder.decode(value, { stream: true })); + } + // Flush any remaining bytes in the decoder + cacheChunks.push(decoder.decode()); + } finally { + cacheReader.releaseLock(); + } + })(); + + // Pipe the client branch to the response + const clientReader = clientBranch.getReader(); + try { + for (;;) { + const { done, value } = await clientReader.read(); + if (done) break; + res.write(value); + } + } finally { + clientReader.releaseLock(); + } + + // Wait for the cache collector to finish (it should already be done + // since the client branch is exhausted) + await collectPromise; + + res.end(suffix); + + return prefix + cacheChunks.join("") + suffix; + } + // Pipe the React body stream through (Suspense content streams progressively) const reader = bodyStream.getReader(); try { @@ -860,7 +915,11 @@ hydrate(); // Stream the page using progressive SSR. // The shell (layouts, non-suspended content) arrives immediately. // Suspense content streams in as it resolves. - await streamPageToResponse(res, element, { + // When ISR is active, ask streamPageToResponse to tee the body + // stream so we get the full HTML for caching without a second render. + const needsIsrCache = + isrRevalidateSeconds !== null && isrRevalidateSeconds > 0; + const collectedHtml = await streamPageToResponse(res, element, { url, server, fontHeadHTML, @@ -875,6 +934,7 @@ hydrate(); typeof headShim.getSSRHeadHTML === "function" ? headShim.getSSRHeadHTML() : "", + collectHtml: needsIsrCache, }); _renderEnd = now(); @@ -883,28 +943,17 @@ hydrate(); routerShim.setSSRContext(null); } - // If ISR is enabled, we need the full HTML for caching. - // For ISR, re-render synchronously to get the complete HTML string. - // This runs after the stream is already sent, so it doesn't affect TTFB. - if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { - let isrElement = AppComponent - ? createElement(AppComponent, { - Component: pageModule.default, - pageProps, - }) - : createElement(pageModule.default, pageProps); - if (wrapWithRouterContext) { - isrElement = wrapWithRouterContext(isrElement); - } - const isrBodyHtml = await renderToStringAsync(isrElement); - const isrHtml = `
${isrBodyHtml}
${allScripts}`; + // If ISR is enabled, store the collected HTML in the cache. + // The body stream was teed during the first render so there is + // no second renderToStringAsync — collectedHtml is already built. + if (needsIsrCache && collectedHtml !== undefined) { const cacheKey = isrCacheKey("pages", url.split("?")[0]); await isrSet( cacheKey, - buildPagesCacheValue(isrHtml, pageProps), - isrRevalidateSeconds, + buildPagesCacheValue(collectedHtml, pageProps), + isrRevalidateSeconds!, ); - setRevalidateDuration(cacheKey, isrRevalidateSeconds); + setRevalidateDuration(cacheKey, isrRevalidateSeconds!); } } catch (e) { // Let Vite fix the stack trace for better dev experience From d8a227510e747c8ea70127eb235dc86322a44751 Mon Sep 17 00:00:00 2001 From: James Date: Tue, 10 Mar 2026 09:03:17 +0000 Subject: [PATCH 2/2] test: update Pages Router server entry snapshot for stream tee ISR change --- .../entry-templates.test.ts.snap | 42 ++++++++++++------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index ec670a42..93fe0af5 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -15578,22 +15578,36 @@ export async function renderPage(request, url, manifest) { } }); - // Cache the rendered HTML for ISR (needs the full string — re-render synchronously) + // Cache the rendered HTML for ISR. + // Tee the composite stream so one branch is returned to the caller + // (streamed to the client) while the other is collected in the + // background for the ISR cache. No second render is required. + var responseStream = compositeStream; if (isrRevalidateSeconds !== null && isrRevalidateSeconds > 0) { - // Tee the stream so we can cache and respond simultaneously would be ideal, - // but ISR responses are rare on first hit. Re-render to get complete HTML for cache. - var isrElement; - if (AppComponent) { - isrElement = React.createElement(AppComponent, { Component: PageComponent, pageProps }); - } else { - isrElement = React.createElement(PageComponent, pageProps); - } - isrElement = wrapWithRouterContext(isrElement); - var isrHtml = await renderToStringAsync(isrElement); - var fullHtml = shellPrefix + isrHtml + shellSuffix; + var [responseStream, cacheStream] = compositeStream.tee(); var isrPathname = url.split("?")[0]; var isrCacheKey = "pages:" + (isrPathname === "/" ? "/" : isrPathname.replace(/\\/$/, "")); - await isrSet(isrCacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds); + // Collect the cache branch and write to ISR after the stream drains. + // This runs in the background and does NOT block the response. + (async () => { + var decoder = new TextDecoder(); + var cacheChunks = []; + var cacheReader = cacheStream.getReader(); + try { + for (;;) { + var chunk = await cacheReader.read(); + if (chunk.done) break; + cacheChunks.push(decoder.decode(chunk.value, { stream: true })); + } + cacheChunks.push(decoder.decode()); + } finally { + cacheReader.releaseLock(); + } + var fullHtml = cacheChunks.join(""); + await isrSet(isrCacheKey, { kind: "PAGES", html: fullHtml, pageData: pageProps, headers: undefined, status: undefined }, isrRevalidateSeconds); + })().catch((err) => { + console.error("[vinext] ISR cache write failed:", err); + }); } // Merge headers/status/cookies set by getServerSideProps on the res object. @@ -15622,7 +15636,7 @@ export async function renderPage(request, url, manifest) { if (_fontLinkHeader) { responseHeaders.set("Link", _fontLinkHeader); } - return new Response(compositeStream, { status: finalStatus, headers: responseHeaders }); + return new Response(responseStream, { status: finalStatus, headers: responseHeaders }); } catch (e) { console.error("[vinext] SSR error:", e); return new Response("Internal Server Error", { status: 500 });